Spaces:
Running
Running
| """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 | |
| 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], | |
| } | |
| ) | |