AI Bank Statement Extraction#
PdfImport uses Anthropic Claude or OpenAI GPT to automatically classify an uploaded PDF and extract its transactions. The pipeline has two stages:
- Classification (
process_with_ai) — identifies the document type (e.g.,bank_statement,credit_card_statement,investment_statement) and extracts high-level metadata (institution, period, balances). - Transaction extraction (
extract_transactions) — runs only forbank_statement/credit_card_statementdocuments; calls the provider'sextract_bank_statementmethod to pull every transaction from the PDF.
The entire flow is driven asynchronously by ProcessPdfJob (medium_priority queue). After extraction, rows are materialized via generate_rows_from_extracted_data and the import status is set to :pending for user review .
PdfImport skips the standard CSV import workflow entirely — requires_csv_workflow? returns false .
Processing Pipeline#
PdfImport#process_with_ai_later
└─ ProcessPdfJob#perform
├─ PdfImport#process_with_ai ← Stage 1: classify + metadata
│ └─ Provider.process_pdf
│ └─ PdfProcessor#process (Anthropic / OpenAI)
├─ [if bank_statement or credit_card_statement]
│ PdfImport#extract_transactions ← Stage 2: pull every transaction
│ └─ Provider.extract_bank_statement
│ └─ BankStatementExtractor#extract
├─ PdfImport#generate_rows_from_extracted_data
└─ PdfImport#sync_mappings
Triggering the job — process_with_ai_later acquires a lock, transitions the import to :importing, and enqueues ProcessPdfJob. A guard at the top of perform releases stuck :importing claims older than 30 minutes back to :pending .
Provider selection — both stages call Provider::Registry.preferred_llm_provider, which resolves Anthropic or OpenAI based on Setting.llm_provider and valid credentials. Both providers expose process_pdf and extract_bank_statement methods with the same interface .
Vector store upload — after classification, the PDF is also uploaded to the family's vector store for retrieval. Failures here are logged as warnings but do not abort the job .
Stage 1 – Document Classification (PdfProcessor)#
Both providers implement a PdfProcessor class for the classification stage. The Anthropic version at Provider::Anthropic::PdfProcessor sends the raw PDF as a base64-encoded document content block (native PDF support), while the OpenAI version renders it differently.
The PdfProcessor forces a structured output via a single tool call (report_document_analysis) and returns a PdfProcessingResult with:
| Field | Description |
|---|---|
document_type | One of: bank_statement, credit_card_statement, investment_statement, financial_document, contract, other |
summary | Human-readable description |
extracted_data | Institution name, period dates, transaction count, balances, currency, account holder |
The Anthropic processor enforces a 32 MB raw-PDF cap (accounts for base64 overhead in the request body) . max_tokens defaults to 4,096 and is overridden via ANTHROPIC_MAX_TOKENS .
Classification instructions are strict: the model is told to be factual, return null for unclear fields, and never invent figures .
Stage 2 – Transaction Extraction (BankStatementExtractor)#
The two provider implementations take very different approaches:
Anthropic (Provider::Anthropic::BankStatementExtractor)#
- Sends the entire PDF as a single native document block (base64,
application/pdf) in one API call . - Forces a single
report_bank_statementtool call returning a structured schema withbank_name,account_holder,account_number,statement_period,opening_balance,closing_balance, and atransactionsarray. - Each transaction requires
date(YYYY-MM-DD),description, andamount(negative = debit) . - No deduplication — since the whole PDF is processed as one unit, duplicate-row merging would incorrectly collapse legitimately identical same-day transactions .
- Detects response truncation via
stop_reason == :max_tokensand logs a warning . RaiseANTHROPIC_MAX_TOKENSenv var if statements are being cut short. - Records token usage + Langfuse spans for every call .
OpenAI (Provider::Openai::BankStatementExtractor)#
- Uses
PDF::Readerto extract page text, then splits pages into chunks of up to 3,000 characters . - Each chunk is sent as a separate chat completion call with
response_format: { type: "json_object" }. - Statement metadata (bank name, period, balances) is captured from the first chunk only .
- After all chunks are processed, transactions are deduplicated across adjacent chunks: same
date + amount + nameappearing in consecutive chunks (index delta ≤ 1) is treated as a chunking artifact and dropped . - Known limitation: legitimate identical transactions at chunk boundaries can be incorrectly removed (acknowledged in comments at ).
Both extractors normalize transactions to { date, amount, name, category, notes } before returning .
Key Files#
| File | Role |
|---|---|
app/jobs/process_pdf_job.rb | Pipeline orchestrator; stages 1 + 2, vector store upload, status transitions |
app/models/pdf_import.rb | PdfImport model; process_with_ai, extract_transactions, generate_rows_from_extracted_data |
app/models/provider/anthropic/pdf_processor.rb | Stage 1 classification — Anthropic (native PDF document block) |
app/models/provider/anthropic/bank_statement_extractor.rb | Stage 2 extraction — Anthropic (single-call, no dedup) |
app/models/provider/openai/bank_statement_extractor.rb | Stage 2 extraction — OpenAI (chunked text, dedup across adjacent chunks) |
app/models/provider/anthropic.rb | Provider::Anthropic; process_pdf and extract_bank_statement entry points, Langfuse tracing, usage recording |
app/models/provider/openai.rb | Provider::Openai; same entry points; supports_pdf_processing? can be disabled via OPENAI_SUPPORTS_PDF_PROCESSING=false |
app/models/provider/registry.rb | Provider selection via Setting.llm_provider |