from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response as FastAPIResponse from pydantic import BaseModel from sqlalchemy import and_, or_ from sqlmodel import Session, col, func, select from app.auth import get_current_user from app.db import get_session from app.api.v1.endpoints.notifications import send_push_to_all from app.models.models import ( Category, Currency, Transaction, TransactionCreate, TransactionRead, TransactionSource, TransactionType, TransactionUpdate, ) from app.services.budget_projection import ( NOT_INSTALLMENT_ANCHOR, get_cycle_range, get_previous_cycle, ) from app.services.csv_export import build_transactions_csv from app.services.exchange_rate import get_converted_amount_expr from app.services.installments import ( DEFAULT_NUM_INSTALLMENTS, create_plan, is_tasa_cero_merchant, teardown_plan_for_anchor, ) router = APIRouter(prefix="/transactions", tags=["transactions"]) class BillingCycle(BaseModel): year: int month: int label: str count: int total: float def auto_categorize(merchant: str, session: Session) -> Optional[int]: categories = session.exec(select(Category)).all() merchant_lower = merchant.lower() for cat in categories: if cat.auto_match_patterns: patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")] if any(p in merchant_lower for p in patterns if p): return cat.id return None @router.get("/", response_model=list[TransactionRead]) def list_transactions( source: Optional[TransactionSource] = None, exclude_source: Optional[TransactionSource] = None, search: Optional[str] = None, category_id: Optional[int] = None, cycle_year: Optional[int] = None, cycle_month: Optional[int] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, limit: int = Query(default=50, le=500), offset: int = 0, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): query = select(Transaction) if source: query = query.where(Transaction.source == source) if exclude_source: query = query.where(Transaction.source != exclude_source) if category_id: query = query.where(Transaction.category_id == category_id) if search: query = query.where(col(Transaction.merchant).ilike(f"%{search}%")) if cycle_year and cycle_month: start, end = get_cycle_range(cycle_year, cycle_month) prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month) prev_start, prev_end = get_cycle_range(prev_y, prev_m) # Normal transactions in this cycle (not deferred) + deferred from previous cycle query = query.where( or_( and_( Transaction.date >= start, Transaction.date < end, Transaction.deferred_to_next_cycle == False, # noqa: E712 ), and_( Transaction.date >= prev_start, Transaction.date < prev_end, Transaction.deferred_to_next_cycle == True, # noqa: E712 ), ) ) elif start_date and end_date: query = query.where( Transaction.date >= datetime.fromisoformat(start_date), Transaction.date < datetime.fromisoformat(end_date), ) query = query.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).offset(offset).limit(limit) return session.exec(query).all() class BulkActionRequest(BaseModel): ids: list[int] action: str # 'delete' | 'set_category' | 'set_deferred' category_id: Optional[int] = None # for set_category (None clears) deferred: Optional[bool] = None # for set_deferred class BulkActionResponse(BaseModel): affected: int @router.post("/bulk", response_model=BulkActionResponse) def bulk_action( req: BulkActionRequest, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): """Apply one action to many transactions atomically (review 4.4).""" if not req.ids or len(req.ids) > 500: raise HTTPException(status_code=400, detail="ids must contain 1-500 entries") if req.action not in ("delete", "set_category", "set_deferred"): raise HTTPException(status_code=400, detail=f"Unknown action: {req.action}") if req.action == "set_deferred" and req.deferred is None: raise HTTPException(status_code=400, detail="deferred is required for set_deferred") if req.action == "set_category" and req.category_id is not None: if session.get(Category, req.category_id) is None: raise HTTPException(status_code=404, detail="Category not found") txs = session.exec( select(Transaction).where(col(Transaction.id).in_(req.ids)) ).all() affected = 0 for tx in txs: if req.action == "delete": teardown_plan_for_anchor(session, tx) session.delete(tx) elif req.action == "set_category": tx.category_id = req.category_id session.add(tx) elif req.action == "set_deferred": if tx.installment_plan_id: continue # cuotas of a plan cannot be deferred tx.deferred_to_next_cycle = bool(req.deferred) session.add(tx) affected += 1 session.commit() return {"affected": affected} @router.get("/export") def export_transactions_csv( source: Optional[TransactionSource] = None, transaction_type: Optional[TransactionType] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): """Download transactions as CSV (filters optional). Cookie-authenticated, so a plain download works from the SPA.""" csv_text = build_transactions_csv( session, source=source, transaction_type=transaction_type, start_date=datetime.fromisoformat(start_date) if start_date else None, end_date=datetime.fromisoformat(end_date) if end_date else None, ) return FastAPIResponse( content=csv_text, media_type="text/csv; charset=utf-8", headers={ "Content-Disposition": 'attachment; filename="wealthysmart-transactions.csv"' }, ) @router.get("/cycles", response_model=list[BillingCycle]) def list_billing_cycles( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): """Return available billing cycles based on transaction dates.""" # Get date range of all transactions result = session.exec( select(func.min(Transaction.date), func.max(Transaction.date)) ).first() if not result or not result[0]: return [] min_date, max_date = result amount_crc = get_converted_amount_expr(session) cycles = [] # Determine which cycle the min_date falls into if min_date.day < 18: # Falls in previous month's cycle if min_date.month == 1: y, m = min_date.year - 1, 12 else: y, m = min_date.year, min_date.month - 1 else: y, m = min_date.year, min_date.month while True: start, end = get_cycle_range(y, m) if start > max_date: break # Count transactions in this cycle count_result = session.exec( select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= start, Transaction.date < end, NOT_INSTALLMENT_ANCHOR, ) ).first() count = count_result[0] if count_result else 0 total = float(count_result[1]) if count_result else 0.0 if count > 0: month_names = [ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ] end_month = m + 1 if m < 12 else 1 end_year = y if m < 12 else y + 1 label = f"{month_names[m]} 18 - {month_names[end_month]} 18, {end_year}" cycles.append(BillingCycle(year=y, month=m, label=label, count=count, total=total)) # Next month if m == 12: y, m = y + 1, 1 else: m += 1 return list(reversed(cycles)) @router.get("/recent", response_model=list[TransactionRead]) def recent_transactions( limit: int = Query(default=5, le=20), session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): query = ( select(Transaction) .where(Transaction.source == TransactionSource.CREDIT_CARD) .order_by(col(Transaction.date).desc(), col(Transaction.id).desc()) .limit(limit) ) return session.exec(query).all() @router.post("/", response_model=TransactionRead, status_code=201) def create_transaction( data: TransactionCreate, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): tx = Transaction.model_validate(data) # Duplicate detection by reference if tx.reference: existing = session.exec( select(Transaction).where(Transaction.reference == tx.reference) ).first() if existing: raise HTTPException( status_code=409, detail=f"Duplicate transaction: reference '{tx.reference}' already exists (id={existing.id})", ) if tx.category_id is None: tx.category_id = auto_categorize(tx.merchant, session) session.add(tx) session.commit() session.refresh(tx) # Tasa Cero auto-detection: BAC prefixes financed purchases with "CC ". # Default 3 cuotas starting on the purchase date; editable afterwards via # /installment-plans. tasa_cero_note = "" if ( tx.source == TransactionSource.CREDIT_CARD and tx.transaction_type == TransactionType.COMPRA and is_tasa_cero_merchant(tx.merchant) ): create_plan(session, tx, DEFAULT_NUM_INSTALLMENTS, tx.date) session.commit() session.refresh(tx) tasa_cero_note = f" — Tasa Cero {DEFAULT_NUM_INSTALLMENTS} cuotas" # Send push notification symbols = {Currency.CRC: "₡", Currency.USD: "$", Currency.EUR: "€"} symbol = symbols.get(tx.currency, tx.currency.value) amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}" is_income = tx.transaction_type in (TransactionType.DEPOSITO, TransactionType.SALARY) is_salary = tx.transaction_type == TransactionType.SALARY label = "salario" if is_salary else ("depósito" if is_income else tx.transaction_type.value.lower()) send_push_to_all( session, title=f"{'🏦' if is_income else '💳'} {tx.merchant}", body=f"{amount_str} — {tx.bank.value} {label}{tasa_cero_note}", url="/salarios" if is_income else "/budget", ) if is_salary: from app.services.savings_accrual import maybe_apply_monthly_savings maybe_apply_monthly_savings(session, tx) return tx @router.patch("/{transaction_id}", response_model=TransactionRead) def update_transaction( transaction_id: int, data: TransactionUpdate, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): tx = session.get(Transaction, transaction_id) if not tx: raise HTTPException(status_code=404, detail="Transaction not found") update_data = data.model_dump(exclude_unset=True) if update_data.get("deferred_to_next_cycle") is not None and tx.installment_plan_id: raise HTTPException( status_code=400, detail="Cuotas of an installment plan cannot be deferred", ) for key, value in update_data.items(): setattr(tx, key, value) session.add(tx) session.commit() session.refresh(tx) return tx @router.delete("/{transaction_id}", status_code=204) def delete_transaction( transaction_id: int, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): tx = session.get(Transaction, transaction_id) if not tx: raise HTTPException(status_code=404, detail="Transaction not found") teardown_plan_for_anchor(session, tx) session.delete(tx) session.commit()