Add deferred transactions, revamp budget projections and UI

Adds deferred_to_next_cycle flag to transactions for billing cycle
bleed-over handling. Overhauls budget projection engine and refreshes
Budget page with improved monthly detail and transaction columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-03 20:10:23 -06:00
parent 37e04273b9
commit 0fdb5447b7
11 changed files with 845 additions and 276 deletions

View File

@@ -19,19 +19,11 @@ from app.models.models import (
TransactionUpdate,
)
from app.services.budget_projection import get_cycle_range, get_previous_cycle
router = APIRouter(prefix="/transactions", tags=["transactions"])
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
class BillingCycle(BaseModel):
year: int
month: int
@@ -54,6 +46,7 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
@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,
@@ -68,13 +61,32 @@ def list_transactions(
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)
query = query.where(Transaction.date >= start, Transaction.date < end)
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),