DEV Community

cleanstmt
cleanstmt

Posted on

Building a Financial Document OCR with Claude Vision API: Lessons from Production

After processing thousands of bank statements, invoices, and receipts through Claude Vision API, I've learned that financial document OCR is harder than it looks. Here's what actually works in production.

The Problem: Why Traditional OCR Fails on Financial Documents

Traditional OCR tools like Tesseract or AWS Textract struggle with financial documents for three reasons:

  1. Table structure is implicit — Banks don't use HTML tables. Columns are separated by whitespace, making it hard to know where one column ends and another begins.
  2. Numbers must be perfect — Confusing 1 with l or 0 with O creates accounting errors. A single misread digit can break double-entry bookkeeping.
  3. Format chaos — Every bank uses different layouts. Chase statements look nothing like Wells Fargo statements.

Traditional OCR gives you raw text. You still need to write hundreds of lines of regex to parse it into structured data.

Why Claude Vision API Changes the Game

Claude Vision doesn't just extract text — it understands document structure. You give it an image and a prompt like:

"Extract this bank statement into JSON with transaction date, description, debit, credit, and balance columns."

Claude returns structured JSON directly. No regex. No manual column detection.

Real Example

Input: Bank statement PDF (converted to PNG)

Prompt:

Extract all transactions from this bank statement. Return JSON with:
- header: {accountNumber, statementPeriod, bankName}
- transactions: [{date, description, debit, credit, balance}]

Rules:
- Dates in YYYY-MM-DD format
- All amounts as numbers (no currency symbols)
- If a field is unclear, use null (never guess)
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "header": {
    "accountNumber": "****1234",
    "statementPeriod": "2024-01-01 to 2024-01-31",
    "bankName": "Chase Bank"
  },
  "transactions": [
    {
      "date": "2024-01-03",
      "description": "Amazon.com",
      "debit": 49.99,
      "credit": null,
      "balance": 1450.01
    },
    {
      "date": "2024-01-05",
      "description": "Salary Deposit",
      "debit": null,
      "credit": 3500.00,
      "balance": 4950.01
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

No parsing code. No regex. Just structured data ready for your database.

Production Challenges and Solutions

Challenge 1: Low-Quality Scans

Problem: Users upload phone photos of statements — blurry, skewed, poor lighting.

Solution: Preprocess images before sending to Claude:

from PIL import Image, ImageEnhance

def preprocess_image(img_path):
    img = Image.open(img_path)

    # Convert to grayscale (reduces noise)
    img = img.convert('L')

    # Increase contrast
    enhancer = ImageEnhance.Contrast(img)
    img = enhancer.enhance(2.0)

    # Resize if too large (Claude Vision has 5MB limit)
    if img.size[0] > 2000:
        ratio = 2000 / img.size[0]
        img = img.resize((2000, int(img.size[1] * ratio)))

    return img
Enter fullscreen mode Exit fullscreen mode

Result: Accuracy improved from 78% to 94% on mobile-captured statements.

Challenge 2: Multi-Page Statements

Problem: Statements can be 5-10 pages. Sending all pages in one request:

  • Hits token limits
  • Increases latency
  • Costs more

Solution: Process first + last page only for most use cases:

  • First page: Account info, statement period, opening balance
  • Last page: Closing balance, summary

For full transaction history, batch-process middle pages and merge results.

import anthropic

def extract_statement_summary(pdf_pages):
    """Extract key info from first and last page only"""
    client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

    # Process first page
    first_page = pdf_pages[0]
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": "image/png",
                        "data": first_page
                    }
                },
                {
                    "type": "text",
                    "text": "Extract account number, statement period, opening balance as JSON."
                }
            ]
        }]
    )

    # Parse JSON from response
    summary = json.loads(response.content[0].text)
    return summary
Enter fullscreen mode Exit fullscreen mode

Cost savings: $0.15 per statement → $0.03 per statement (5× reduction)

Challenge 3: Decimal Point Errors

Problem: Claude occasionally misreads 1,234.56 as 123456 or 12.34.

Solution: Add validation rules in your prompt:

Rules for amount extraction:
1. All amounts must have exactly 2 decimal places
2. If you see "1,234.56", extract as 1234.56
3. If you see "1234", extract as 1234.00
4. If unclear whether "1234" means 1234.00 or 12.34, use null
5. Never guess — uncertain values must be null
Enter fullscreen mode Exit fullscreen mode

Also validate in code:

def validate_amount(amount):
    if amount is None:
        return None
    # Ensure 2 decimal places
    return round(float(amount), 2)
Enter fullscreen mode Exit fullscreen mode

Result: Decimal errors dropped from 3.2% to 0.4%.

Challenge 4: Model Fallback

Problem: Claude Sonnet 4 sometimes rate-limits during peak hours.

Solution: Implement model fallback:

MODELS = [
    "claude-sonnet-4-20250514",  # Primary
    "claude-3-5-sonnet-20241022", # Fallback
    "claude-3-haiku-20240307"     # Last resort
]

def extract_with_fallback(image_data):
    for model in MODELS:
        try:
            response = client.messages.create(
                model=model,
                max_tokens=12000,
                messages=[...]
            )
            return response
        except anthropic.RateLimitError:
            continue
    raise Exception("All models rate-limited")
Enter fullscreen mode Exit fullscreen mode

Result: 99.7% uptime even during peak usage.

Challenge 5: Handling Edge Cases

Problem: Real-world statements have weird formats:

  • Split transactions across pages
  • Negative balances shown as (1234.56) instead of -1234.56
  • Missing dates (e.g., pending transactions)

Solution: Explicit edge case handling in prompts:

Edge cases:
- Amounts in parentheses like "(123.45)" mean negative (debit)
- If a transaction has no date, use the statement end date
- If balance column is empty, calculate it from previous balance +/- amount
- "Pending" transactions go in a separate "pending" array
Enter fullscreen mode Exit fullscreen mode

And post-process in code:

def normalize_transaction(txn):
    # Convert parentheses to negative
    if txn['debit'] and '(' in str(txn['debit']):
        txn['debit'] = -float(txn['debit'].strip('()'))

    # Fill missing dates
    if not txn['date']:
        txn['date'] = statement_end_date

    return txn
Enter fullscreen mode Exit fullscreen mode

Cost Optimization

Financial document OCR can get expensive. Here's what we learned:

Optimization Cost Impact Accuracy Impact
Process first+last page only -80% -5% (acceptable for summaries)
Use Haiku for simple receipts -90% -2% (receipts are easier)
Batch similar documents -30% +3% (context helps)
Prompt caching (reuse bank-specific rules) -50% No change

Current costs: $0.03 per bank statement, $0.01 per receipt using this setup.

Accuracy Metrics from Production

After 10,000+ documents processed:

Document Type Accuracy Notes
Digital bank statements (PDF) 98.2% High contrast, clean layout
Scanned bank statements 94.1% Preprocessed with contrast enhancement
Mobile photos of statements 91.7% Users must follow photo guidelines
Invoices (structured) 96.8% Consistent format helps
Receipts (printed) 89.4% Small text, low contrast
Handwritten receipts 72.3% Use case too hard for automation

"Accuracy" = extracted data matches manual review.

When NOT to Use Claude Vision

Claude Vision isn't perfect for:

  • Handwritten documents — Accuracy drops to 70-80%
  • Real-time processing — Latency is 2-4 seconds per page (too slow for POS systems)
  • High-security contexts — Data leaves your infrastructure (though Anthropic doesn't train on API data)

For these cases, consider AWS Textract + custom parsing or on-premise OCR.

Key Takeaways

  1. Preprocessing matters — Contrast enhancement and grayscale conversion boost accuracy 10-15%
  2. Validate everything — Never trust raw OCR output. Check decimals, date formats, and null handling
  3. Explicit prompts win — The more rules you specify, the fewer surprises in production
  4. Cost-optimize aggressively — Full-page processing is overkill for most use cases
  5. Model fallback is essential — Rate limits happen. Have backup models ready

Try It Yourself

Want to test Claude Vision on your own statements? I built CleanStmt as a free tool to convert bank statements to Excel/CSV using the techniques above.

Source code for the preprocessing pipeline: GitHub (coming soon)


What's your experience with financial document OCR? Drop a comment if you've hit similar challenges or found better solutions.

ai #ocr #claude #fintech #python

Top comments (0)