Add pension PDF upload, parsing, and fund summary API
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s

Backend: parse BAC pension statement PDFs (VOL, ROP, FCL) via
pdftotext, store snapshots with duplicate detection, reject
credit card statements. Endpoints: POST /upload, GET /snapshots,
GET /fund-summary.

Frontend: wire up drag-and-drop upload, load real balances and
rendimientos from API, show upload results with error/duplicate
feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-28 22:24:42 -06:00
parent 1b90f0c70a
commit eccfd53e0b
8 changed files with 631 additions and 56 deletions

View File

@@ -0,0 +1,121 @@
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Bank, PensionSnapshot, PensionSnapshotRead
from app.services.pension_pdf import parse_pension_pdf
router = APIRouter(prefix="/pensions", tags=["pensions"])
class PensionUploadResult(BaseModel):
imported: int
duplicates: int
errors: list[str]
snapshots: list[PensionSnapshotRead]
@router.post("/upload", response_model=PensionUploadResult)
async def upload_pension_pdfs(
files: list[UploadFile],
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
imported = 0
duplicates = 0
errors: list[str] = []
created: list[PensionSnapshot] = []
for file in files:
filename = file.filename or "unknown.pdf"
try:
pdf_bytes = await file.read()
fund_snapshots = parse_pension_pdf(pdf_bytes, filename)
except ValueError as e:
errors.append(str(e))
continue
except Exception as e:
errors.append(f"{filename}: {e}")
continue
for snap in fund_snapshots:
existing = session.exec(
select(PensionSnapshot).where(
PensionSnapshot.fund == Bank(snap.fund),
PensionSnapshot.period_start == snap.period_start,
PensionSnapshot.period_end == snap.period_end,
)
).first()
if existing:
duplicates += 1
continue
row = PensionSnapshot(
fund=Bank(snap.fund),
contract_number=snap.contract_number,
period_start=snap.period_start,
period_end=snap.period_end,
saldo_anterior=snap.saldo_anterior,
aportes=snap.aportes,
rendimientos=snap.rendimientos,
retiros=snap.retiros,
traslados=snap.traslados,
comision=snap.comision,
correccion=snap.correccion,
bonificacion=snap.bonificacion,
saldo_final=snap.saldo_final,
source_filename=filename,
)
session.add(row)
created.append(row)
imported += 1
if imported > 0:
session.commit()
for row in created:
session.refresh(row)
return PensionUploadResult(
imported=imported,
duplicates=duplicates,
errors=errors,
snapshots=[PensionSnapshotRead.model_validate(r) for r in created],
)
@router.get("/snapshots", response_model=list[PensionSnapshotRead])
def get_snapshots(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(PensionSnapshot).order_by(
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
PensionSnapshot.fund,
)
).all()
return rows
@router.get("/fund-summary", response_model=list[PensionSnapshotRead])
def get_fund_summary(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Return the latest snapshot per fund (by most recent period_end)."""
all_rows = session.exec(
select(PensionSnapshot).order_by(
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
)
).all()
seen: set[str] = set()
latest: list[PensionSnapshot] = []
for row in all_rows:
if row.fund.value not in seen:
seen.add(row.fund.value)
latest.append(row)
return latest

View File

@@ -9,6 +9,7 @@ from app.api.v1.endpoints import (
exchange_rate,
import_transactions,
notifications,
pensions,
salarios,
settings,
tokens,
@@ -28,3 +29,4 @@ api_router.include_router(settings.router)
api_router.include_router(budget.router)
api_router.include_router(notifications.router)
api_router.include_router(salarios.router)
api_router.include_router(pensions.router)