Claude AIAWS BedrockPythonDockerSFTPData EngineeringLLM

Building an AI-Powered Travel Claim Validation Pipeline

·6 min read

The Problem

Large organisations process hundreds of employee travel expense claims every month. Each claim consists of a structured JSON payload from the internal workflow system — declaring trip dates, destinations, and expense amounts — alongside scanned PDF receipts as attachments. Finance reviewers were manually cross-checking these two sources, which was slow, inconsistent, and error-prone.

The ask: automate the cross-check. Flag suspect claims. Get results into reviewers' hands every morning.


What the Pipeline Does

At a high level, the pipeline:

  1. Downloads claim JSONs, PDFs, and supporting CSVs from an SFTP server
  2. Sends PDFs to Claude Sonnet (via AWS Bedrock) for structured receipt extraction
  3. Cross-references extracted amounts, names, and dates against the declared JSON payload
  4. Applies accommodation policy caps based on employee grade
  5. Checks whether the declared trip location matches the employee's known geographic presence
  6. Writes a validation report (CSV + JSON + Excel) and persists results to SQLite
  7. Uploads outputs to SFTP and cleans up stale input files

Each night's run covers claims from D, D-1, and D-2 — claims already processed without errors are skipped automatically.


Tech Stack

LayerChoiceWhy
LanguagePython 3.12Rich ML/data ecosystem
LLMClaude Sonnet via AWS BedrockStructured tool-use output, strong OCR on scanned receipts
Fuzzy matchingrapidfuzzFast, token_sort_ratio handles name variations and spelling diversity
PDF processingpdf2image + OpenCVConvert pages to images before sending to the vision model
Datapandas + SQLiteLightweight, no infra overhead; results queryable by Finance
ConfigYAML + frozen dataclassesImmutable configs caught several misconfiguration bugs early
SFTPparamikoBidirectional file exchange with the internal IT SFTP server
PackaginguvFast dependency resolution, lockfile for reproducibility
DeploymentDocker + Ubuntu VM + cronSimple, auditable, no Kubernetes overhead for a nightly batch job

The Journey

Stage 1 — Extraction

The first real challenge was getting structured data out of scanned receipts. The PDFs ranged from clean digital prints to badly photographed handwritten bills.

I settled on a two-step approach: pdf2image converts each page to a compressed JPEG, then Claude Sonnet receives all pages for a case in a single prompt via a tool-use schema. The schema forces the model to output structured fields — amounts by category (accommodation, transport, food), person counts, travel metadata, receipt dates — rather than free text. This made downstream validation deterministic.

Early runs surfaced edge cases: encrypted PDFs, blank pages, Bengali-script receipts, and handwritten amounts. I added quality flags for each (bad_document_quality, has_handwriting, has_bengali) that feed into the final review score.

Stage 2 — Validation

With extracted data in hand, validation became a three-level problem:

Amount matching — compare declared totals (from JSON) against extracted totals (from PDFs) at two levels: an overall L1 check and a per-category L2 breakdown. The threshold is 1% deviation, configurable.

Identity matching — verify the employee name on receipts matches the claimant. token_sort_ratio at threshold 70 handles transcription variation. Name + date matching produces a composite score; below 20 triggers review (only when attachments exist).

Policy enforcement — accommodation reimbursement is capped per night based on employee grade. The grade mapping is looked up from a daily-refreshed CSV that IT drops on SFTP, keyed by employee ID.

Stage 3 — Location Plausibility

The third validation dimension came later: does the declared trip destination make sense for where this employee actually was?

An external service receives a CSV of phone numbers with trip date ranges, runs network location queries, and deposits a result CSV with each subscriber's detected location and up to 5 neighbouring cells.

The locations are in "District-Thana" format (specific to the geography here). The pipeline constructs the same format from the claim's origin/destination fields, then fuzzy-matches using token_sort_ratio at threshold 85. A single match across any row or neighbour column is enough to pass. If the number isn't in the CSV the flag is unidentified; present but no match is not_match.

The Date Rollover Problem

The subscriber CSV is generated at 23:59 using today's date as the filename. The main pipeline runs at ~00:05 — by which point the calendar has rolled over. The location service uses the filename date to know which response CSV to produce.

Fix: the CSV generator writes the run-date string to a small data/.run_date file immediately after generating the CSV. The main pipeline reads this at startup, so even at 00:05 it's looking for the correct previous-day filename.

Stage 4 — Cleanup

Input files accumulate on SFTP. After upload, the pipeline lists the input directory, parses the date embedded in each filename, and deletes anything strictly older than D-2. Files without a parseable date are left untouched.


Deployment

The pipeline runs on a production Ubuntu VM on a two-step cron schedule:

59 23 * * *  # generate subscriber CSV, save run-date
05 00 * * *  # main pipeline: download → poll location → extract → validate → upload → cleanup

The main pipeline polls SFTP every 5 minutes for up to 3 hours waiting for the location CSV before proceeding without it. This gives the location service a comfortable window while keeping the report ready before the Finance team's morning stand-up.

Docker wraps the whole thing for consistent dependency management across environments. The image is built from a slim Python base with poppler-utils for PDF rendering.


What I'd Do Differently

Async extraction. Claude calls are made with a thread pool right now. True async I/O with asyncio would be cleaner and likely faster for large batches.

Schema versioning. The extraction tool schema evolved several times. A proper versioned schema registry would have made it easier to replay old runs against new schemas.

Observability. Rotating log files work for a nightly job, but a lightweight metrics setup would give better visibility into token spend, extraction error rates, and per-category deviation trends over time.