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>
137 lines
4.8 KiB
Python
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
|