Skip to content
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
39 changes: 39 additions & 0 deletions packages/backend/app/db/savings_schema.sql
Original file line number Diff line number Diff line change
@@ -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);
146 changes: 146 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
177 changes: 177 additions & 0 deletions packages/backend/app/routes/savings.py
Original file line number Diff line number Diff line change
@@ -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/<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="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/<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="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/<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="goal not found"), 404
db.session.delete(goal)
db.session.commit()
return jsonify(message="goal deleted"), 200


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