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