MyMidas/backend/app/services/budget_service.py
megaproxy 61a7884ee5 Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:56:10 +00:00

137 lines
4.8 KiB
Python

import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.budget import Budget
from app.db.models.category import Category
from app.db.models.transaction import Transaction
from app.schemas.budget import BudgetCreate, BudgetSummaryItem, BudgetUpdate
def _period_bounds(period: str, ref: date) -> tuple[date, date]:
if period == "weekly":
start = ref - relativedelta(days=ref.weekday())
end = start + relativedelta(days=6)
elif period == "monthly":
start = ref.replace(day=1)
end = (start + relativedelta(months=1)) - relativedelta(days=1)
elif period == "quarterly":
q = (ref.month - 1) // 3
start = date(ref.year, q * 3 + 1, 1)
end = (start + relativedelta(months=3)) - relativedelta(days=1)
else: # yearly
start = date(ref.year, 1, 1)
end = date(ref.year, 12, 31)
return start, end
async def create_budget(db: AsyncSession, user_id: uuid.UUID, data: BudgetCreate) -> Budget:
now = datetime.now(timezone.utc)
budget = Budget(
id=uuid.uuid4(),
user_id=user_id,
category_id=data.category_id,
name=data.name,
amount=data.amount,
currency=data.currency,
period=data.period,
start_date=data.start_date,
end_date=data.end_date,
rollover=data.rollover,
alert_threshold=data.alert_threshold,
is_active=True,
created_at=now,
updated_at=now,
)
db.add(budget)
await db.flush()
await db.refresh(budget)
return budget
async def list_budgets(db: AsyncSession, user_id: uuid.UUID, active_only: bool = True) -> list[Budget]:
q = select(Budget).where(Budget.user_id == user_id)
if active_only:
q = q.where(Budget.is_active == True) # noqa: E712
q = q.order_by(Budget.name)
result = await db.execute(q)
return list(result.scalars().all())
async def get_budget(db: AsyncSession, user_id: uuid.UUID, budget_id: uuid.UUID) -> Budget | None:
result = await db.execute(
select(Budget).where(Budget.id == budget_id, Budget.user_id == user_id)
)
return result.scalar_one_or_none()
async def update_budget(db: AsyncSession, budget: Budget, data: BudgetUpdate) -> Budget:
for field, value in data.model_dump(exclude_unset=True).items():
setattr(budget, field, value)
budget.updated_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(budget)
return budget
async def delete_budget(db: AsyncSession, budget: Budget) -> None:
await db.delete(budget)
await db.flush()
async def get_budget_summary(db: AsyncSession, user_id: uuid.UUID) -> list[BudgetSummaryItem]:
budgets = await list_budgets(db, user_id, active_only=True)
today = date.today()
items: list[BudgetSummaryItem] = []
for budget in budgets:
period_start, period_end = _period_bounds(budget.period, today)
# Fetch category name
cat_result = await db.execute(select(Category).where(Category.id == budget.category_id))
category = cat_result.scalar_one_or_none()
cat_name = category.name if category else "Unknown"
# Sum actual spending in this period
spent_result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount)), Decimal("0")))
.where(
and_(
Transaction.user_id == user_id,
Transaction.category_id == budget.category_id,
Transaction.type == "expense",
Transaction.status != "void",
Transaction.date >= period_start,
Transaction.date <= period_end,
Transaction.deleted_at.is_(None),
)
)
)
spent = Decimal(str(spent_result.scalar() or 0))
remaining = budget.amount - spent
pct = (spent / budget.amount * 100) if budget.amount > 0 else Decimal("0")
items.append(
BudgetSummaryItem(
budget_id=budget.id,
budget_name=budget.name,
category_id=budget.category_id,
category_name=cat_name,
period=budget.period,
budget_amount=budget.amount,
spent_amount=spent,
remaining_amount=remaining,
percent_used=pct.quantize(Decimal("0.01")),
is_over_budget=spent > budget.amount,
alert_triggered=pct >= budget.alert_threshold,
currency=budget.currency,
period_start=period_start,
period_end=period_end,
)
)
return items