Claude AIAWS BedrockPythonDockerSFTPData EngineeringLLM

Building an AI-Powered Travel Claim Validation Pipeline

·7 min read

The Problem

Large organisations process hundreds of employee travel expense claims every month. Each one shows up as two things that are supposed to agree with each other: a structured JSON payload from the internal workflow system declaring trip dates, destinations, and expense amounts, and a stack of scanned PDF receipts backing it up. Finance reviewers were cross-checking these by hand, which was slow, inconsistent, and easy to get wrong.

So the goal became pretty simple to state, even if it wasn't simple to build: automate the cross-check, flag anything suspicious, and have results waiting for reviewers every morning.


What the Pipeline Does

At a high level, the pipeline pulls claim JSONs, PDFs, and supporting CSVs off an SFTP server, sends the PDFs to Claude Sonnet via AWS Bedrock for structured receipt extraction, then cross-references the extracted amounts, names, and dates against the declared JSON payload. From there it applies accommodation policy caps based on employee grade, checks whether the declared trip location lines up with the employee's known geographic presence, writes a validation report as CSV, JSON, and Excel, persists results to SQLite, and finally uploads everything back to SFTP and cleans up stale input files.

Each night's run covers claims from D, D-1, and D-2. Anything already processed without errors gets skipped automatically — no point re-validating a claim that already passed.


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

Getting structured data out of scanned receipts

This was the first real challenge, and it turned out to be harder than I expected. The PDFs ranged from clean digital prints to badly photographed handwritten bills, and there was no telling which you'd get until you opened the file.

I settled on a two-step approach: pdf2image converts each page to a compressed JPEG, and 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 — instead of free text. That one decision is what made downstream validation deterministic instead of a parsing exercise.

Early runs surfaced the edge cases you'd expect once real-world documents hit the pipeline: encrypted PDFs, blank pages, Bengali-script receipts, handwritten amounts. Rather than trying to handle every case perfectly, I added quality flags for each — bad_document_quality, has_handwriting, has_bengali — and let those feed into the final review score.

Cross-checking the numbers

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

Amount matching compares declared totals from the JSON against extracted totals from the PDFs, at two levels — an overall check and a per-category breakdown. The threshold is 1% deviation, and it's configurable.

Identity matching verifies the employee name on receipts actually matches the claimant. token_sort_ratio at a threshold of 70 handles the usual transcription variation you get from OCR. Name and date matching combine into a composite score, and anything below 20 triggers a review — but only when attachments actually exist.

Policy enforcement caps accommodation reimbursement per night based on employee grade. The grade mapping comes from a CSV that IT refreshes daily on SFTP, keyed by employee ID.

Does the trip location even make sense?

This third validation dimension came later, once the obvious checks were in place. The question it answers: does the declared trip destination make sense for where this employee actually was?

An external service takes a CSV of phone numbers with trip date ranges, runs network location queries against them, and hands back a result CSV with each subscriber's detected location plus up to 5 neighbouring cells.

The locations come back in "District-Thana" format, which is specific to the geography here. The pipeline builds the same format from the claim's origin and destination fields, then fuzzy-matches using token_sort_ratio at a threshold of 85 — a single match across any row or neighbour column is enough to pass. If the phone number isn't in the CSV at all, the flag is unidentified; if it's there but nothing matches, it's not_match.

The Date Rollover Problem

The subscriber CSV gets generated at 23:59 using today's date as the filename. The main pipeline runs about five minutes later, at 00:05 — and by then the calendar has already rolled over to a new day. The location service, meanwhile, is still using the filename date to know which response CSV to produce. Two clocks, technically agreeing, producing a mismatch anyway.

The fix was simpler than the bug: the CSV generator writes its run-date string to a small data/.run_date file right after generating the CSV. The main pipeline reads that file at startup, so even at 00:05 it knows to look for the correct previous-day filename.

Cleaning up after itself

Input files pile up on SFTP if nothing removes them. After upload, the pipeline lists the input directory, parses the date embedded in each filename, and deletes anything strictly older than D-2. Anything without a parseable date gets left alone — better to accumulate a few stray files than delete something by mistake.


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 giving up and proceeding without it. That gives the location service a generous window to finish while still keeping the report ready ahead of Finance's morning stand-up.

Docker wraps the whole thing so dependencies behave the same way everywhere it runs. The image is built from a slim Python base with poppler-utils added for PDF rendering.


What I'd Do Differently

Async extraction. Claude calls run through a thread pool right now. Real async I/O with asyncio would be cleaner and probably faster once batches get large.

Schema versioning. The extraction tool schema has changed shape a few times already. A proper versioned schema registry would make it much less painful to replay old runs against a newer schema.

Observability. Rotating log files are fine for a nightly job, but I'd like better visibility into token spend, extraction error rates, and per-category deviation trends over time — something a lightweight metrics setup would give me for free.