"""POST /convert — PDF in, reconciled statement data out (JSON or .xlsx). Stateless: the uploaded PDF is read into memory, processed in an isolated temp dir, and deleted before the response is built. Nothing is persisted. """ from __future__ import annotations import io import logging from fastapi import APIRouter, File, Header, HTTPException, Query, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from openpyxl import Workbook from app.core.config import API_KEY, MAX_UPLOAD_BYTES from app.core.files import temp_pdf from app.engine.converter import BankStatementConverter, ExtractedStatement router = APIRouter() logger = logging.getLogger("ebs.convert") XLSX_MEDIA = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" def _is_pdf(file: UploadFile) -> bool: if (file.content_type or "").lower() in {"application/pdf", "application/x-pdf"}: return True return (file.filename or "").lower().endswith(".pdf") def _statement_dict(st: ExtractedStatement) -> dict: return { "account": st.account, "accountHolder": st.account_holder, "period": st.period, "openingBalance": st.opening_balance, "closingBalance": st.closing_balance, "reconciles": st.reconciles, "reconcileStatus": st.reconcile_status, "runningBalanceOk": st.running_balance_ok, "statedSummaryOk": st.stated_summary_ok, "reconciliationDetail": st.reconciliation_detail, "transactions": [ { "date": t.date, "description": t.description, "debit": t.debit, "credit": t.credit, "balance": t.balance, } for t in st.transactions ], } def _xlsx_bytes(statements: list[ExtractedStatement]) -> io.BytesIO: wb = Workbook() wb.remove(wb.active) for n, st in enumerate(statements, 1): ws = wb.create_sheet(f"Account{n}") ws.append(["Date", "Description", "Debit", "Credit", "Balance"]) for t in st.transactions: ws.append([t.date, t.description, t.debit, t.credit, t.balance]) buf = io.BytesIO() wb.save(buf) buf.seek(0) return buf @router.post("/convert") async def convert( file: UploadFile = File(...), format: str = Query("json", pattern="^(json|xlsx)$"), x_api_key: str | None = Header(default=None), ): if API_KEY and x_api_key != API_KEY: raise HTTPException(status_code=401, detail="Unauthorized.") if not _is_pdf(file): raise HTTPException(status_code=415, detail="Only PDF files are accepted.") data = await file.read() if not data: raise HTTPException(status_code=400, detail="Empty file.") if len(data) > MAX_UPLOAD_BYTES: mb = MAX_UPLOAD_BYTES // (1024 * 1024) raise HTTPException(status_code=413, detail=f"File too large (max {mb} MB).") try: with temp_pdf(data) as pdf_path: statements = BankStatementConverter(pdf_path).extract() except Exception: # Bad user input (corrupt / password-protected / not a real PDF) must # surface as 4xx, never a 500. Logged for observability. logger.exception("extraction failed for upload %r", file.filename) raise HTTPException( status_code=422, detail="Couldn't read this PDF. It may be corrupt, password-protected, or not a valid PDF.", ) if not statements or all(not s.transactions for s in statements): raise HTTPException( status_code=422, detail="Couldn't read any transactions. If this is a scanned image, a clearer scan may help.", ) if format == "xlsx": return StreamingResponse( _xlsx_bytes(statements), media_type=XLSX_MEDIA, headers={"Content-Disposition": 'attachment; filename="statement.xlsx"'}, ) return JSONResponse( { "count": sum(len(s.transactions) for s in statements), "statements": [_statement_dict(s) for s in statements], } )