Files
WealthySmart/backend/app/api/v1/endpoints/transactions.py
Carlos Escalante 3cdd7d9eab Constraint and query pass: FK delete rules, grouped queries, tiebreakers
- FK rules via migration 7884505b16b3 (verified on dev with a rolled-
  back probe): transactions/recurring items SET NULL on category delete
  (this also fixes the 500 that DELETE /categories raised for in-use
  categories), water readings CASCADE with their receipt. Models declare
  ondelete to stay in sync. (ARCH-09, BE-11)
- compute_actuals_by_source: 3 grouped queries replace 10 single-
  aggregate round-trips; behavior pinned by the existing cycle suite.
  (BE-21)
- compute_cc_by_category prefetches category names (ARCH-15); agent
  pension latest-per-fund resolved in SQL (BE-04); municipal water
  consumption batched into one grouped query (BE-05).
- Deterministic pagination: date DESC, id DESC on all transaction
  listings. (ARCH-12)
- New agent-tool tests (session binding, JSON-safe output, batched
  queries): 64 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:45:46 -06:00

249 lines
8.3 KiB
Python

from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
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 get_cycle_range, get_previous_cycle
from app.services.exchange_rate import get_converted_amount_expr
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
from sqlalchemy import or_, and_
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()
@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
)
).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)
# 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}",
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)
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")
session.delete(tx)
session.commit()