SpenDrop
SpenDrop: tri-platform personal finance and on-device receipt intelligence (iOS, Android, Web)
Product Design, iOS Architecture, Android Jetpack Compose App, Web App, ML Kit/Vision OCR Engines, Tests
Operational Workflow & State Machine
End-to-End Pipeline- 01Share a payment screenshot
- 02OCR on the phone
- 03Classify amounts and detect provider
- 04Check for duplicates
- 05Confirm on the review form
- 06Save to the shared store
Overview
Most everyday payments in Malaysia now end with a confirmation screen: Touch 'n Go, a bank app, DuitNow QR, Apple Pay. SpenDrop turns that screen into an expense. Take a screenshot, share it to SpenDrop, check the form it fills in, and save. Cash spending goes in by hand with Quick Cash.
It is a native iOS app with a Share Extension, written in Swift and SwiftUI with SwiftData for storage and Apple's Vision framework for OCR. Reading and parsing run on the phone, and the app is local-first: it works offline with no account, and there is no third-party package. Version 1.4.0 adds optional sign-in with cloud backup to Supabase. It is verified on the simulator; it is not on the App Store.
Problem
Expense apps ask you to type what your phone already shows you. The payment screen has the amount, the merchant, the provider and a reference number, but copying them by hand is slow enough that most people stop doing it. And a payment confirmation is financial data: sending it to a server to be read is a poor trade for convenience.
From screenshot to expense
The Share Extension receives the image from any app's share sheet. Vision reads the text on the phone, on an image downsampled to 1280 pixels to stay inside the extension's memory limit, and the lines are sorted into rows. A rule-based parser then works out what the screen is: which provider, which merchant, which category, the date and time, and the reference number. The extension shows the result as a review form; nothing is saved until the user confirms.
The app and the extension are two separate processes, so they share one SwiftData store through an App Group. An expense saved from the share sheet is on the dashboard the next time the app opens, and the duplicate check in the extension can see everything the app has saved.
Choosing the right amount
A payment screen rarely has one number on it. A Touch 'n Go confirmation can carry an advert for an RM 450 air conditioner under an RM 18.50 payment; a receipt lists a subtotal, a service charge and a total; a bank screen shows the balance. The parser labels every RM value it finds (total, fee, balance, cashback, discount, advertisement) and chooses from the valid candidates only. When several remain, the review form shows them as one-tap alternatives; when one remains, the row is hidden.
Screens that are not payments at all, such as a balance, a credit limit or reward points, are recognised and are not saved as expenses, or are flagged for review. Sharing the same screenshot twice is caught by its reference number, or by the same merchant and amount on the same day within a 48-hour window, and the user decides whether to add it anyway.
Payment channels, accounts and reconciliation
A payment answers two separate questions, and the app keeps them separate. The Payment Channel is how it was paid: Apple Pay, QR payment, DuitNow QR, bank transfer, online banking, card, e-wallet or cash. The Funding Account is where the money came from, a bank, an e-wallet or cash. Detection is conservative: when the screen does not say, the channel stays Unknown rather than being guessed. Since 1.4.0 funding accounts are real records, with money recorded in and out per account, and deliberately no balance field, because the app records activity rather than reading a bank.
The same purchase often arrives twice: once as an Apple Pay tap and again, days later, as the bank's own record. Before saving, a reconciliation engine looks for an existing expense with the same amount within 48 hours. When it finds one it merges the two, filling in whatever the first record lacked (channel, funding account, merchant, reference) instead of adding a second expense. Separate duplicate checks warn on a repeated reference, or the same merchant and amount close together, for both expenses and money records, and never delete anything on their own.
Categories learn from use: saving or correcting an expense teaches the app, on the device, which category a merchant belongs to.
The rest of the app
Around capture sits the rest of the app, in five tabs. Home shows today, week and month spending, with cash flow and balances when they apply. Transactions is one filterable timeline of expenses, money in, money out and transfers. PayBook holds people and their payment details, with calculated balances and recorded repayments. Breakdown, built with Swift Charts, splits spending and cash flow by category, payment channel, account and merchant. More holds accounts, settings and a self-test runner that executes the app's in-app checks on the device. A Shortcuts action, Log Apple Pay Purchase, lets the Wallet transaction automation hand an Apple Pay tap straight to SpenDrop.
Version 1.4: shared money, safe data and optional backup
Version 1.4.0 turns the expense log into a small personal-finance model without making normal entry any longer. Each number answers one question: an expense's amount is always the full bill, spending is my share of it, money in and money out cover income, refunds, loans and repayments, net cash flow is the difference, and a person's balance says who owes whom. A bill can be split equally, by parts or by exact amounts, whoever paid, and all of this arithmetic runs in integer sen so a split always adds up exactly. Balances are calculated when they are shown, never stored.
The data is protected before any of that ships. The SwiftData store moved to an explicit versioned schema with a migration plan, a copy is taken before every upgrade, and a store that cannot be opened puts the app into a safe mode that deletes nothing. Local backups keep a dated history and export to JSON. Sign-in is optional: Google through OAuth PKCE or email, with the session in the Keychain, and it adds an append-only cloud backup to Supabase, with Row Level Security policies written to restrict each row to its owner. A restore merges by record and takes a local safety copy first.
What went wrong, and what it taught
Adverts looked like payments. A Touch 'n Go confirmation can show an RM 450 air-conditioner banner under an RM 22.00 transfer, and any pattern for an RM amount matches both. The fix gives every amount a meaning before any is chosen: advertisement keywords, the words on neighbouring lines, and position, using Vision's bounding boxes, since adverts sit in the bottom of the screen. Layout turned out to be a stronger signal than the text alone.
Interbank transfers name two banks. A CIMB to Maybank receipt mentions both, and the expense belongs to CIMB. Keyword matching alone was ambiguous, so the parser now reads the screen as a document: it collects the banks named after a recipient label first, then reads the from-block, and never attributes a payment to a bank that only appears as the recipient. Apple Pay is treated the same way, as a wrapper around the bank behind it.
Column-aligned receipts still break it. On a synthetic receipt with the label and the amount far apart, Vision returned "TOTAL" and "RM 19.08" as separate observations, the total lost its label, and a line item won. The review screen correctly downgraded to "Possible Expense Detected", and the case is recorded as a known limitation rather than hidden.
Testing and verification
The app carries its own test runner, run in memory or in temporary folders so the real database is never opened. It has grown to 274 checks across twelve suites: parsing, providers and real reference screenshots; data safety, from safe mode to frozen schemas and backups; the money model, splits and migrations; accounts and money in and out; shared expenses and PayBook balances; the unified timeline; and sign-in and cloud backup against a simulated Supabase server. Six XCUITest flows drive the app itself: tabs, adding an expense, a split with its PayBook balance, accounts with money in and a transfer, the cash-flow views and the account screen.
The repository records the run on 29 September 2026 on the iOS 27 simulator: 274 of 274 in-app checks and 6 of 6 UI tests passed, and both the app and the Share Extension built. The parser checks feed synthetic OCR text, so they test the parser, not Vision's recognition quality. There is no CI yet, and version 1.4.0 has not yet been verified on a physical iPhone.
Every screenshot on this page is from the real app on a simulator, captured before version 1.4.0. The payment screenshot and the receipt it reads are synthetic images made for testing; PayBook shows the app's built-in sample payees.
What's live, and what isn't
“It exists in the repository” and “it runs in production” are different claims. This is the difference, stated rather than left to be assumed.
- Built, not liveCapture, parsing, review, duplicates, history, analytics, PayBookWorking on the simulator; earlier builds also ran on the developer's iPhone. Not published on the App Store or TestFlight.
- Built, not liveIn-app checks and UI tests274 of 274 in-app checks and 6 of 6 XCUITest flows recorded on 29 September 2026 on the iOS 27 simulator. Version 1.4.0 not yet verified on a physical iPhone.
- Built, not liveSplits, money in and out, PayBook balances, accountsIn version 1.4.0, verified on the simulator by the in-app checks and UI tests.
- Built, not liveOptional sign-in and cloud backup (Supabase)Implemented with Google (OAuth PKCE) or email sign-in and an append-only backup with Row Level Security policies. Tested against a simulated server; the policies have not yet been run on a live Supabase project.
- Not connectedCamera capture, multi-currency amounts, CINot implemented yet: capture is from screenshots and Photos, and the currency preference is stored but all amounts are in RM.
Technical stack
App
Capture
Parsing
Data & money
Accounts & cloud (optional)
Verification
Limitations
- The parser is keyword-driven: an unfamiliar layout falls back to "Unknown" with low confidence.
- Column-aligned paper receipts can lose the label on the total, so a line item may be proposed instead.
- One synthetic screenshot read 9:42 PM as 9:42 AM.
- Some local saves still ignore errors, and deleting an expense leaves its receipt image on disk.
- Cloud backups are protected by HTTPS, sign-in and Row Level Security but are not end-to-end encrypted, and receipt images are not backed up.
- Account totals are recorded activity, not live bank balances.
- iPhone only, portrait only, English only; no CI.
- The repository is private. Access can be granted on request.
What's next
- 01Verify sign-in and cloud backup on a live Supabase project and a physical iPhone
- 02Replace the remaining ignored saves with handled errors
- 03CI running the build, the in-app checks and the UI tests
- 04Camera capture, transfers from the Share Extension, and currency support
- 05Optional receipt images in backups, and deleting them with their expenses
Source and further reading
Finished the technical breakdown?
Return to the concise HR overview or discover other projects in the portfolio.
Writing from this work
Offline Receipt OCR on Android: On-Device Google ML Kit Text Recognition with Jetpack Compose
How SpenDrop's native Android engine parses payment receipts and merchant summaries offline on device using Google ML Kit, Jetpack Compose, and Room SQLite without third-party cloud APIs.
Sub-Millisecond Saves: Designing a Local-First Outbox and Sync Engine in SwiftData
How SpenDrop achieved an on-device save latency of 0.240 ms in SwiftData while synchronising in the background with a remote Supabase PostgreSQL backend using the persistent outbox pattern.
Two Ways I Use Supabase Row Level Security: Deny-All Behind a Server, or Per-Owner Policies
Across five projects, RLS does one of two jobs: close the Data API while a trusted server does the work, or let Postgres enforce who owns each row. When to pick which.
Testing Without Paid APIs or Hardware, and the Places Fakes Lied
Fake LLM providers, a simulated label printer and an in-process Postgres keep my test suites free and offline. What each proved, and three places a fake fell short.
SwiftData Migrations That Can't Lose Data: VersionedSchema, Pre-Upgrade Copies and a Safe Mode
SwiftData's inferred migration silently dropped a table. How SpenDrop moved to VersionedSchemas, pre-upgrade copies and a safe mode that never deletes.
Google Sign-In on iOS Through Supabase, With PKCE and No Google SDK
Google sign-in on iOS through Supabase with ASWebAuthenticationSession and PKCE, no Google SDK or client secret, plus an identity bug RLS would reject.
Splitting RM 10 Three Ways: Integer Sen and the Largest-Remainder Method
Floating-point money drifts and naive splits lose a sen. Integer sen at one boundary and the largest-remainder method, so shares always add up.
Sorting Apple Vision Text Into Lines Without Crashing Swift's Sort
VNRecognizeTextRequest returns boxes, not lines. Sorting them top to bottom and left to right with a comparator Swift's sort can trust.
Telling the Sender Bank From the Recipient on a Malaysian Transfer Receipt
A CIMB to Maybank transfer receipt names both banks. Reading the receipt's structure to attribute the payment to the bank that actually sent it.
Defaults Are Claims: Removing Placeholder Numbers That Looked Like Measurements
A 75% tank, a 10 km/L economy, a hardcoded user id: defaults in DriveKeep that rendered as real data, what replaced them, and the same rule in SpenDrop.
Apple Pay Is Not a Bank: Payment Channels, Funding Accounts and Reconciling Double Records
One purchase can arrive as an Apple Pay tap and again as a bank record. Separating how you paid from where money came from, and merging the duplicate.
Spending, Cash Flow and Who Owes Whom: Modelling Shared Bills in a Personal Finance App
When a bill is shared, 'how much did I spend?' has three honest answers. SpenDrop's rules for spending, cash flow, refunds and balances.
Screens That Look Like Payments but Aren't: Parsing Malaysian Receipts Without Fake Expenses
Balance, credit-limit and reward screens all show RM amounts. How SpenDrop rejects them, and the Malaysian receipt formats two of my parsers read.
Logging Apple Pay Taps Automatically With an App Intent and a Wallet Automation
An App Intent lets the Shortcuts Wallet automation hand each Apple Pay purchase to SpenDrop. The intent, its duplicate guard, and what iOS lets apps see.
Building an iOS Share Extension That Reads a Screenshot and Saves to the App's Database
How SpenDrop's Share Extension loads an image from any app, stays inside the memory limit, runs Vision OCR and saves to the app's shared SwiftData store.
Testing an iOS App With an In-App Test Runner, Launch Flags and XCUITest
274 logic checks run inside the app from a launch flag, a final check proves no test opened the real database, and six XCUITest flows drive the UI.
Implemented, Available, Not Connected: Writing Honest Status Into Project Docs
"It exists in the repository" and "it runs in production" are different claims. The three-state status model I use across my projects, with real rows from each.
Generating an Xcode Project File From a Script, With Stable IDs
Both of my iOS apps write project.pbxproj from a script. How the object IDs are made, why hashing names beats a counter, and what it costs.
An Append-Only Cloud Backup for a Local-First iOS App
SpenDrop's optional cloud backup never overwrites: per-user Storage folders, no UPDATE policies, a content hash, and a restore that copies local data first.
The SwiftData Store I Configured Was Never the One Being Used
Renaming an iOS app opened an empty database. The App Group store URL I had configured had never worked; SwiftData's fallback had been doing the job.
Which RM Is the Payment? Classifying Every Amount on a Receipt Screenshot
A payment screenshot shows fees, balances and advert prices as well as the payment. How SpenDrop labels every RM value, using keywords and screen position.



