6.4 KiB
Receipt parsing (implementation)
This is the working guide for Receipity's receipt parser. Use it when a new shop's receipt does not parse well.
The original design notes live in receipt-parsing-spec.md.
Keywords and regexes live in receipt-parsing-keywords.json
and are bundled as a Flutter asset.
What the parser produces
For each receipt:
| Field | Meaning |
|---|---|
items[].name |
Product name |
items[].quantity |
Pack count when N X unit was found (otherwise omitted) |
items[].unitPrice |
Price of one pack when quantity is set |
items[].discount |
Negative amount folded into the line (promos) |
items[].price |
Line total after discount (this is what the trip saves) |
store |
Best-effort store from the header + known_stores |
receiptTotal |
First grand-total amount after the items |
totalDiscount |
Receipt-level Totaal korting (and similar) |
validationPassed |
Item sum matches receiptTotal within €0.01 |
If validation fails, Review shows: Parsed totals do not match the receipt.
Pipeline
- Preprocess each OCR line: trim, collapse spaces, drop empty lines.
- Choose a layout
- Sequential (default): name, price, quantity and discount appear in reading order. Jumbo (printed), Lidl, Kruidvat, World Toko.
- Column OCR fallback: ML Kit sometimes dumps every name, then every amount. Detected when there is a run of 4+ price-only lines and enough name-like lines. Used for the Jumbo sample dump in tests.
- Classify each line (first match wins) using the keyword JSON.
- Build items (pending name, then quantity/discount/price).
- Validate against the receipt total when one was found.
Do not treat weight in the name (900g, 500ml) as quantity. Only N X
next to a price is a quantity.
Line types
Priority is the same as the spec:
- Separators / column headers (
OMSCHRIJVING,Artikel,Prijs, …) - Tax / BTW tables (
BTW,Bedr.Excl,B 9%) - Payment (
Betaald,VISA,PIN, …) — also matches OCRBetaal d: - Noise / footer (URLs, opening hours, thank-you lines)
- Receipt-level discount total (
Totaal korting) - Subtotal / tax-inclusive total (ignored for items)
- Grand total (
Totaal,Total) — stops sequential item parsing when an amount is on the same line - Discount (
ACTIE,KORTING,In prijs verlaagd, or a negative amount) - Quantity modifier (
2 X 2,79, optionalPER STUK, optional line total) - Item (name + price at end; optional tax letter
B/Cis stripped) - Price-only
- Name-only (starts a pending item — Jumbo multi-pack)
Quantity variants
| Variant | Example | Store |
|---|---|---|
A — name, then N X unit |
AARDBEIEN / 2 X 3,99 |
Jumbo, World Toko |
B — name and N X unit on one line |
ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK 7,98 |
Kruidvat |
The JSON standalone quantity regex is extended in code so a right-hand
line total is allowed: 2 X 2,79 5,58.
Discounts
A discount is attached to the current / previous item and subtracted from
price. Receipt-wide totals (Totaal korting) are not products.
How to add a new shop
Work in this order. You usually only need step 1.
1. Keywords (receipt-parsing-keywords.json)
| Add to | When |
|---|---|
known_stores.list |
Header contains a new banner name |
column_header_keywords.list |
New table titles (Omschrijving, Amount, …) |
discount_keywords.list |
Promo wording (Bonus, Sparing, …) |
total_keywords.* |
Other words for subtotal / grand total |
payment_keywords.list |
Card brands, Betaald met … |
tax_breakdown_keywords.list |
VAT table labels |
noise_footer_keywords.list |
Loyalty, hours, slogans that were parsed as items |
quantity_modifier_pattern |
Only if N X looks different (2x, 2 *, à) |
Matching rules implemented in code (not only the JSON comment):
- Keywords of 3 characters or fewer use a word boundary (
PINmust not matchSPINAZIE). - Column headers and item-count lines must be the whole line (so
Prijsas a header does not hide Lidl'sIn prijs verlaagd).
After editing the JSON, rebuild the app (it is loaded as an asset at startup). Tests load the same file from the project root.
2. Store quirks table
Add a row here and in receipt-parsing-spec.md section 6:
| Store | Layout | Notes |
|---|---|---|
| Jumbo | A | ACTIE <name> + negative amount. Tax letter B/C far right. OCR may split names vs amounts → column fallback. |
| Lidl | sequential | In prijs verlaagd -0,50. Ignore Bedr.Excl / B 9% rows. |
| Kruidvat | B | N X price PER STUK on the product line. KORTING … may be one line later. |
| World Toko | A / simple | Artikel / Prijs headers. Weight in the name is not quantity. |
| (new shop) | A / B / columns | Short description of the odd lines |
3. Canonical store name
If OCR should pre-select the supermarket on Review:
- Add the banner string to
known_stores.list - Add an alias in
lib/utils/supermarkets.dart(kSupermarketAliases) - Map the uppercase banner in
ReceiptParser._canonicalStore
4. Fixture test
- Scan a real receipt, copy Raw OCR text from Review.
- Add a test in
test/receipt_parser_test.dartwith that dump. - Assert product names, a couple of prices, quantity lines, and that headers / totals / payment lines are not items.
Keep the raw dump in the test (or a test/fixtures/ file) so the next person
can see the real layout.
5. Only then change parser code
Change lib/services/receipt_parser.dart if the new layout is a new
structure (not just new words): extra columns, quantity written as
2 stuks à 1,50, discounts above the product, etc.
Prefer a small, documented branch over a one-off special case for one shop.
Files
| File | Role |
|---|---|
receipt-parsing-keywords.json |
Editable keyword / regex config |
receipt-parsing-spec.md |
Design: classification order, item algorithm |
lib/services/receipt_parse_config.dart |
Loads JSON, keyword matching |
lib/services/receipt_parser.dart |
Classify → items → validate |
test/receipt_parser_test.dart |
Layout fixtures |
Out of scope (parser)
- Product categories
- Matching names to the existing catalog
- Translating receipts; add another keyword list if you need a second language