From 571428f5ac92a41567b40e31772e2448337be861 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 10 Jun 2026 18:03:55 -0600 Subject: [PATCH] CSV export for transactions with download buttons (ARCH-18) GET /api/v1/transactions/export streams a CSV (optional source/type/ date filters), cookie-authenticated so window.open downloads work. Buttons on the Budget transactions tab (all) and Salarios (SALARY only). Quoting and category resolution covered by tests. Co-Authored-By: Claude Fable 5 --- backend/app/api/v1/endpoints/transactions.py | 29 ++++++++++ backend/app/services/csv_export.py | 57 ++++++++++++++++++++ frontend/src/pages/Salarios.tsx | 11 +++- 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/csv_export.py diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py index ceb53ec..41bf0d2 100644 --- a/backend/app/api/v1/endpoints/transactions.py +++ b/backend/app/api/v1/endpoints/transactions.py @@ -2,6 +2,7 @@ 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 @@ -21,6 +22,7 @@ from app.models.models import ( ) from app.services.budget_projection import 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 router = APIRouter(prefix="/transactions", tags=["transactions"]) @@ -97,6 +99,33 @@ def list_transactions( return session.exec(query).all() +@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), diff --git a/backend/app/services/csv_export.py b/backend/app/services/csv_export.py new file mode 100644 index 0000000..7adac97 --- /dev/null +++ b/backend/app/services/csv_export.py @@ -0,0 +1,57 @@ +"""CSV export of transactions (review ARCH-18 / wishlist #4).""" + +import csv +import io +from datetime import datetime +from typing import Optional + +from sqlmodel import Session, col, select + +from app.models.models import Transaction, TransactionSource, TransactionType + +COLUMNS = [ + "id", "date", "merchant", "city", "amount", "currency", + "transaction_type", "source", "bank", "category", "notes", "reference", +] + + +def build_transactions_csv( + session: Session, + *, + source: Optional[TransactionSource] = None, + transaction_type: Optional[TransactionType] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, +) -> str: + q = select(Transaction) + if source: + q = q.where(Transaction.source == source) + if transaction_type: + q = q.where(Transaction.transaction_type == transaction_type) + if start_date: + q = q.where(Transaction.date >= start_date) + if end_date: + q = q.where(Transaction.date < end_date) + q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()) + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(COLUMNS) + for tx in session.exec(q).all(): + writer.writerow( + [ + tx.id, + tx.date.isoformat(), + tx.merchant, + tx.city or "", + f"{tx.amount:.2f}", + tx.currency.value, + tx.transaction_type.value, + tx.source.value, + tx.bank.value, + tx.category.name if tx.category else "", + tx.notes or "", + tx.reference or "", + ] + ) + return buf.getvalue() diff --git a/frontend/src/pages/Salarios.tsx b/frontend/src/pages/Salarios.tsx index d64d432..bc6e786 100644 --- a/frontend/src/pages/Salarios.tsx +++ b/frontend/src/pages/Salarios.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { type ColumnDef } from '@tanstack/react-table'; -import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react'; +import { Landmark, RefreshCw, Hash, CalendarDays, Banknote, Download } from 'lucide-react'; import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api'; import { formatAmount, formatDate } from '@/lib/format'; @@ -98,6 +98,15 @@ export default function Salarios() {

Historial de depósitos salariales

+