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 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 18:03:55 -06:00
parent 4ceb67564a
commit 571428f5ac
3 changed files with 96 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response as FastAPIResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from sqlmodel import Session, col, func, select 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.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 from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/transactions", tags=["transactions"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -97,6 +99,33 @@ def list_transactions(
return session.exec(query).all() 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 <a href> 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]) @router.get("/cycles", response_model=list[BillingCycle])
def list_billing_cycles( def list_billing_cycles(
session: Session = Depends(get_session), session: Session = Depends(get_session),

View File

@@ -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()

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; 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 { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format'; import { formatAmount, formatDate } from '@/lib/format';
@@ -98,6 +98,15 @@ export default function Salarios() {
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p> <p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
</div> </div>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
title="Descargar salarios como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh"> <Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} /> <RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button> </Button>