05/31 Phase 3

This commit is contained in:
2026-05-31 16:18:37 -04:00
parent 9f2a0acc38
commit 1560041cdb
13 changed files with 1152 additions and 10 deletions
+146
View File
@@ -0,0 +1,146 @@
"""
Budget Service — calculates spending vs budget limits per category per month.
"""
from decimal import Decimal
from sqlalchemy import func
from app.extensions import db
from app.models.budget import Budget
from app.models.transaction import Transaction
from app.models.category import Category
import calendar
from datetime import date
def get_month_spending(category_id, month_str):
"""
Return total spending for a category in a given month.
month_str: 'YYYY-MM'
"""
year, month = map(int, month_str.split('-'))
last_day = calendar.monthrange(year, month)[1]
date_from = date(year, month, 1)
date_to = date(year, month, last_day)
result = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.category_id == category_id,
Transaction.transaction_type == 'expense',
Transaction.date >= date_from,
Transaction.date <= date_to,
).scalar()
return float(result)
def get_budget_summary(month_str):
"""
Return list of dicts with budget vs actual for each budgeted category.
Also includes unbudgeted categories that have spending.
"""
year, month = map(int, month_str.split('-'))
last_day = calendar.monthrange(year, month)[1]
date_from = date(year, month, 1)
date_to = date(year, month, last_day)
# All budgets for this month
budgets = Budget.query.filter_by(month=month_str).all()
budgeted_cat_ids = {b.category_id for b in budgets}
# Spending per category this month
spending_rows = db.session.query(
Transaction.category_id,
func.sum(Transaction.amount).label('total')
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date_from,
Transaction.date <= date_to,
Transaction.category_id != None,
).group_by(Transaction.category_id).all()
spending_map = {row.category_id: float(row.total) for row in spending_rows}
summary = []
# Budgeted categories
for b in budgets:
spent = spending_map.get(b.category_id, 0.0)
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
remaining = limit - spent
pct = min(round((spent / limit * 100), 1), 100) if limit > 0 else 0
summary.append({
'budget': b,
'category': b.category,
'spent': spent,
'limit': limit,
'remaining': remaining,
'pct': pct,
'is_over': spent > limit,
'has_budget': True,
})
# Unbudgeted categories with spending
for cat_id, spent in spending_map.items():
if cat_id not in budgeted_cat_ids:
cat = db.session.get(Category, cat_id)
if cat:
summary.append({
'budget': None,
'category': cat,
'spent': spent,
'limit': None,
'remaining': None,
'pct': None,
'is_over': False,
'has_budget': False,
})
# Sort: budgeted first (by % used desc), then unbudgeted
summary.sort(key=lambda x: (not x['has_budget'], -(x['pct'] or 0)))
return summary
def get_total_budget(month_str):
"""Total budgeted amount for a month."""
budgets = Budget.query.filter_by(month=month_str).all()
return sum(float(b.limit_amount) + float(b.rollover_amount or 0) for b in budgets)
def get_total_spent(month_str):
"""Total expense spending for a month (all categories)."""
year, month = map(int, month_str.split('-'))
last_day = calendar.monthrange(year, month)[1]
result = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date(year, month, 1),
Transaction.date <= date(year, month, last_day),
).scalar()
return float(result)
def apply_rollovers(from_month, to_month):
"""
Copy budgets from one month to next, applying rollover amounts.
Call on 1st of each month.
"""
from_budgets = Budget.query.filter_by(month=from_month).all()
for fb in from_budgets:
existing = Budget.query.filter_by(
month=to_month, category_id=fb.category_id
).first()
if not existing:
spent = get_month_spending(fb.category_id, from_month)
limit = float(fb.limit_amount)
rollover = max(limit - spent, 0) if fb.rollover_enabled else 0
new_budget = Budget(
category_id=fb.category_id,
month=to_month,
limit_amount=fb.limit_amount,
rollover_enabled=fb.rollover_enabled,
rollover_amount=rollover,
)
db.session.add(new_budget)
db.session.commit()
+89
View File
@@ -0,0 +1,89 @@
"""
Goal Service — projected completion, emergency fund calc.
"""
from datetime import date
from dateutil.relativedelta import relativedelta
from app.extensions import db
from app.models.goal import Goal
from app.models.transaction import Transaction
from sqlalchemy import func
def get_projected_completion(goal):
"""
Estimate completion date based on average monthly contribution.
Returns date or None if can't be calculated.
"""
if goal.is_completed:
return goal.completed_at
remaining = float(goal.target_amount) - float(goal.current_amount)
if remaining <= 0:
return date.today()
contributions = goal.contributions.all()
if len(contributions) < 2:
return None
# Average monthly contribution from history
contribs_sorted = sorted(contributions, key=lambda c: c.date)
first = contribs_sorted[0].date
last = contribs_sorted[-1].date
total_contrib = sum(float(c.amount) for c in contribs_sorted)
months_elapsed = (
(last.year - first.year) * 12 + (last.month - first.month)
) or 1
avg_monthly = total_contrib / months_elapsed
if avg_monthly <= 0:
return None
months_needed = remaining / avg_monthly
projected = date.today() + relativedelta(months=int(months_needed) + 1)
return projected
def get_emergency_fund_status():
"""
Calculate emergency fund status:
- 3-month and 6-month expense targets
- Current liquid assets (checking + savings + cash)
"""
from app.models.account import Account
# Average monthly expense (last 3 months)
today = date.today()
three_months_ago = today - relativedelta(months=3)
total_expenses = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= three_months_ago,
Transaction.date <= today,
).scalar()
avg_monthly = float(total_expenses) / 3
# Liquid assets
liquid = db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type.in_(['checking', 'savings', 'cash']),
).scalar()
liquid = float(liquid)
months_covered = (liquid / avg_monthly) if avg_monthly > 0 else 0
return {
'avg_monthly_expense': avg_monthly,
'liquid_assets': liquid,
'target_3mo': avg_monthly * 3,
'target_6mo': avg_monthly * 6,
'months_covered': round(months_covered, 1),
'pct_3mo': min(round((liquid / (avg_monthly * 3)) * 100, 1), 100) if avg_monthly > 0 else 0,
'pct_6mo': min(round((liquid / (avg_monthly * 6)) * 100, 1), 100) if avg_monthly > 0 else 0,
}