Reading Greek receipts from a photo: AADE QR codes first, Tesseract second
Every month I hand in my fuel and taxi receipts: one scanned page with all of them on it, plus a list of dates and amounts. Typing 15 receipts by hand from crumpled thermal paper gets old fast, so I wrote a tool that does it from a phone photo: receipt-flow. It runs completely locally (Python, OpenCV, Tesseract, zxing-cpp) and you can use it as a command-line tool, a REST API or an MCP server.
The trick that makes it reliable is not the OCR. It's the QR code that every Greek receipt has carried since November 2023.
What it returns
For every receipt it finds in a photo, exactly four things:
date:YYYY-MM-DD, ornullif it could not be readamount: the total in euro, ornullfrom_code:trueif the amount came from the QR code / barcode,falseif it was read from the printed textconfidence: 0 to 1, how sure it is that the date and the amount are both right
One photo can hold several receipts, lying in any direction, so a photo gives you a list.
Installation
brew install tesseract tesseract-lang # Greek + English language data
git clone https://github.com/kpatr1981/receipt-flow.git
cd receipt-flow
python3 -m venv .venv && .venv/bin/pip install -r requirements.txtOn Debian/Ubuntu use apt install tesseract-ocr tesseract-ocr-ell instead of brew.
Usage: the CLI
Three fuel receipts on the table, one photo:
$ bin/receipt-read fuel-october.jpeg
fuel-october.jpeg
2026-10-13 50.00 code 0.99
2026-10-13 30.00 code 0.99
2026-10-20 20.00 code 0.99Add --json for something you can pipe into jq, and pass as many photos as you like. The exit code is 1 if any photo could not be opened.
Why the QR code matters
Greek cash registers print a QR code that points to the tax authority (AADE). Most of them look like this:
https://www1.aade.gr/tameiakes/myweb/q1.php?SIG=DMW25009721000085977D569F78468776A75B03E328C34461261013142850.00The SIG value is just fields glued together, no separators:
DMW25009721: the cash register (3 letters + 8 digits)00008597: the receipt counter (8 digits)7D569F78468776A75B03E328C34461: a hex signature (30 or 40 characters)2610131428: date and time as YYMMDDHHMM, so 13/10/2026 14:28 (optional, some registers leave it out)50.00: the total
That's an exact date and an exact total, no OCR involved. The catch: the hash is hex, so digits from the hash can look like part of the date or the amount. The parser tries the known layouts first (30- or 40-char hash, with or without date-time) and, when a split is still ambiguous, lets the printed total pick between the candidates.
Fuel pumps often print a different QR, a ;-separated record:
2;0016831;0438144490643;2026-10-20;17:36:20;A;1350370;A;1;10;2;2,129;9,39;20,00;1118469Here the date is an ISO date, followed later by price per litre (2,129), litres (9,39) and total (20,00). 2.129 × 9.39 = 19.99, the pump rounds to 20.00.
Not every code carries values. E-invoice providers print a link to the issued document, and some taximeters print a numeric PDF417 barcode. Those are still useful for one thing: the same code twice means the same receipt, so the web app flags duplicates when you upload the same receipt in two photos.
How it works
For every photo:
- Turn it upright. zxing-cpp reports each QR code's orientation, so the photo is rotated by the majority vote of its codes. No codes? Tesseract reads the photo in all four directions and the one with the most real receipt words wins (ΑΠΟΔΕΙΞΗ, ΣΥΝΟΛΟ, ΦΠΑ...).
- Find the receipts. Instead of looking for paper edges (white receipt on a white table, shadows...), OpenCV finds lines of printed text and groups them into columns. Two receipts touching each other still have two separate columns of text. Handwriting is dropped by its ink density.
- Straighten each one. Each crop is deskewed by trying small angles and keeping the one where the text rows are sharpest.
- QR first. Decode the codes inside that receipt and parse them as above.
- Then OCR. Tesseract (
ell+eng) runs on a plain grayscale copy first, then with coloured stamps removed, then contrast-stretched for faded thermal paper, and stops as soon as it has a date and a total. - Pick the total. Lines labelled ΣΥΝΟΛΟ, ΠΛΗΡΩΤΕΟ or TOTAL; VAT-rate lines like
13,00%are ignored. Fuel receipts are cross-checked as litres × price per litre, others as net + VAT. - Score it. The confidence comes from how the values were read.
The confidence number
It is rule-based, not a model, so you can read exactly why a receipt got its score:
- 0.99: date and total both from the QR code, only one possible reading
- 0.70: total from the code, date from the printed text
- 0.65: printed text only, but the total was confirmed by litres × price or net + VAT
- 0.50: printed text only, a total line and nothing to check it against (also used when the code allows several totals)
- −0.15 when the print shows other plausible totals too
- 0: date or amount missing
I treat anything under 0.99 as "check this one". On my own test set (20 photos, 64 receipts, 2023 to 2026, mostly fuel and taxi) 21 receipts got their total from a code, 12 of them fully exact at 0.99, and 41 of 64 got both a date and a total. The misses were mostly older receipts without a QR code, crumpled paper and taximeter print.
Usage: the REST API
bin/receipt-api --port 8781
curl -s -F photo=@fuel-october.jpeg http://127.0.0.1:8781/v1/receipts{
"request_id": "9823e3f1d48b",
"results": [
{
"file": "fuel-october.jpeg",
"receipts": [
{"amount": 50.0, "confidence": 0.99, "date": "2026-10-13", "from_code": true},
{"amount": 30.0, "confidence": 0.99, "date": "2026-10-13", "from_code": true},
{"amount": 20.0, "confidence": 0.99, "date": "2026-10-20", "from_code": true}
]
}
]
}You can also send the raw image as the body with -H "Content-Type: image/jpeg" --data-binary @photo.jpg. The photo is read from a temporary copy that is deleted straight away. There is no authentication, so keep it on 127.0.0.1 or a network you trust.
Was it right? Request ids and feedback
Every request gets a request_id and lands in a small SQLite log (results only, never the photos). Later you tell it whether the answer was right:
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"rating":"good"}' http://127.0.0.1:8781/v1/requests/9823e3f1d48b/feedback
curl -s "http://127.0.0.1:8781/v1/summary?n=50"{"bad": 0, "good": 1, "last_n": 50, "pending_in_window": 0, "pending_total": 0,
"requests": 1, "success_pct": 100.0, "verified": 1}success_pct: good ÷ verified over the lastnrequestspending_in_window: how many of thosenyou haven't rated yetGET /v1/requests: the backlog of unrated requestsGET /v1/stats: good/bad split by confidence band, which tells you whether 0.70 really means "right 70% of the time"
The same from the terminal: bin/receipt-feedback list, rate ID good, summary 50.
Usage: the MCP server
The MCP server (stdio, official mcp Python SDK) lets an assistant read receipts on your machine:
claude mcp add receiptflow -- "$PWD/bin/receipt-mcp"Tools:
read_receipts(paths)andread_receipt_image(image_base64): the same four fields plus arequest_idrate_request(request_id, rating, note): "that second one was wrong" becomes abadratinglist_requests,request_stats,success_summary(n)
The REST API and the MCP server share one log, so the statistics cover both.
One thing that bit me: MCP clients start servers with a stripped environment, no TMPDIR. pytesseract then writes its temp image to /tmp, which on macOS is a symlink, and in a sandbox Tesseract could not follow it. pytesseract then crashed decoding Tesseract's error message, which contained a PNG header. The fix is one line before anything calls Tesseract:
tempfile.tempdir = os.path.realpath(tempfile.gettempdir())There is also a browser version
The web/ folder is the same pipeline ported to JavaScript, with Tesseract, OpenCV and zxing as WebAssembly. It runs entirely in the browser: the photos never leave your device, and any static web server can host it. It makes the monthly page too: one A4 PDF with the receipts numbered in date order, and a table of dates and amounts to copy.
Limits
- Built for Greek receipts (Greek + English text, euro amounts, AADE codes). Other countries' receipts would need their own QR parser and total words.
- Receipts lying on top of each other can merge into one.
- About 12 seconds per photo on a Mac. Tesseract is the slow part.
- The confidence numbers are hand-set. The feedback log is there to calibrate them with real data.
The code is MIT-licensed: github.com/kpatr1981/receipt-flow. The test photos are not in the repo (they are my receipts), so the tests that need them skip themselves.
$ ls ./see-also/
$ ./buy-me-a-coffee ☕ // found this useful? I write these in my spare time