exportstatement / app /api /convert.py
rudrahf's picture
Claude Opus 4.8 (1M context)
Initial commit — Export Bank Statement extractor (FastAPI)
0ebef7d
Raw
History Blame Contribute Delete
4.13 kB
"""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],
}
)