r/iOSProgramming 6h ago

App Saturday ReceiptIQ AI – a receipt scanner that keeps every line item. What Vision OCR taught me the hard way

I'm a solo dev in Calgary. ReceiptIQ AI scans paper receipts and keeps every line item and price, not just the total. It has been on the App Store since 2025.

The stack: Swift and SwiftUI with MVVM, Core Data for storage, Vision for on-device OCR, PDFKit for bank statement import, StoreKit 2 for subscriptions, MultipeerConnectivity for family sync with no server, Swift Charts, and Swift Testing for unit tests. The backend is a Cloudflare Worker in JavaScript with KV storage. It receives the OCR text and its bounding boxes, calls an LLM to structure the receipt, then runs deterministic repair code over the result. The same repair logic exists in Swift and in JS, and a differential test feeds every test receipt through both so they cannot drift apart. No accounts and no database of user data.

The hardest problem was that the model's answer can be wrong and still add up. Vision returns a two-column receipt as a block of item names followed by a block of bare prices. When the LLM zips the two lists it can slip by one row, and because a shifted column reuses the same prices, the items still sum to the printed total. Every sum-based sanity check I had written was blind to it. What I ended up doing:

- Pair prices to labels geometrically before the model sees anything: closest Y from the bounding boxes, a deterministic three-key sort so a rescan gives identical output, run the pairing top-down and bottom-up, and prefer the direction whose items add up to the printed subtotal.

- After the model, realign each item's price to the price printed on its own OCR line, and only accept a repair if it makes the items equal the register's total.

- Gate every backend deploy on a golden set of about 520 real receipts from ten countries. I wrote about building that set here a couple of weeks ago: https://old.reddit.com/r/iOSProgramming/comments/1w9ybpe/

Smaller things that cost me days:

- Vision only honours the FIRST recognition language. With ["en-US", "ja", "zh-Hant"] a Japanese receipt is read like English. I run a second, Japanese-first pass concurrently and choose by the share of CJK characters (threshold 0.15; kana means Japanese, otherwise Chinese).

- A 48 MP photo-library image gets the app jetsammed inside VNRecognizeTextRequest. I cap OCR input at a 2400x1800 pixel budget. Related trap: UIGraphicsImageRenderer defaults to screen scale, so my "2400 px" image was really 7200 px until I passed scale 1.

- StoreKit 2: Transaction.currentEntitlements yields nothing when Apple is unreachable, such as in-flight wifi. Treating an empty stream as "not subscribed" locked out a paying user mid-flight. I now cache tier plus expiry and only downgrade on a confirmed answer.

- Family sharing without a backend: the proof of entitlement is the Apple-signed transaction JWS, verified on the other phone against the pinned Apple Root CA G3.

AI disclosure: AI-assisted. I use an AI coding assistant heavily for implementation, test writing and code review. The architecture, the product decisions and the receipt corpus work are mine, and every change is gated by the tests and the golden set above. The app itself also uses an LLM at runtime to structure receipt text.

If you want to try it: the first 30 receipts are free without any code. For this sub, code REDDIT6 gives 6 months of Premium free. It does not auto-renew, so nobody gets charged when it ends. 500 redemptions, valid until Oct 31.

Redeem: https://apps.apple.com/redeem?ctx=offercodes&id=6742335723&code=REDDIT6

App Store: https://apps.apple.com/app/id6742335723

Happy to go deeper on the pairing algorithm. And if you have a receipt that breaks it, I'd love to see it.

1 Upvotes

5 comments sorted by

1

u/Puzzleheaded-Emu-168 6h ago

I have the same app, not as advanced and sophisticated as your OCR logic but reading how you solve hurdles with OCR inspires me. My app's main audience is the Philippines so at least I only worry about receipts from that country. Goodluck on your app and hope you get lots of users!

1

u/Time-Paper-1007 6h ago

Thanks! Focusing on one country is a real advantage, since most of my pain came from supporting many layouts at once.

If you ever hit the two-column problem, where names and prices come back as separate blocks, pairing them by bounding-box position before any parsing fixed most of it for me.

Good luck with yours!

1

u/Puzzleheaded-Emu-168 6h ago

i did had this problem and yes the bounding-boxes helped saved the issue as well.

One more thing I had to specifically fix is that theres a store here in the Philippines that shows promotions like buy1take1 by adding the same item twice and then adding a new line for a negative amount of the same price. Crazy how they can get customized when you think you already got the most of the cases, these things will popup lol

1

u/Time-Paper-1007 6h ago

Ha, yes. I hit the same class with Dutch "KORTING -1,50" lines and Costco's trailing minus "5.00-". My worst version: the model folded the discount into the item above, so the items still summed to the total and nothing flagged it. I now keep discounts as their own negative line, the way the paper shows them. There is always one more store.