Case study

Even Up

Scan the receipt. Split it fairly.

RoleSolo. Product, code, tests, App Store.
Built withSwift, SwiftUI, WidgetKit, a TypeScript function on Vercel
TimelineFirst commit June 9, 2026. Released October 9.
StatusLive, 1.0. Published under my dad’s developer account, since Apple requires developers to be 18.
A saved bill with its scanned receipt.
Even Up's home screen with a net balance of $206.80.
A split bill showing each person's share.

Why it exists

Who paid for what?

My friends and I kept losing track of who had paid for dinner, who covered the ride, and who still owed whom.

Even Up keeps one running balance per person. Scan a receipt and tap who had what, or log a single entry, and it works out each share with tax and tip. Record a payment and it comes off the oldest debts first.

No accounts, no ads, no server holding your records. Everything lives on the phone, with an optional iCloud backup, a Home Screen widget and Face ID lock.

What I built

An app, a backend, and the tests that keep them honest.

The app

  • The ledger and the money math behind every split
  • Every screen in SwiftUI, from Home to the bill editor
  • Home Screen widgets sharing data through an App Group
  • Receipt scanning with a consent screen in front of it

Around it

  • The scanning backend on Vercel, calling Google Gemini
  • Nine test harnesses that compile the app’s real code
  • The privacy policy and App Store privacy answers
  • The submission, the rejection and the appeal

How I build. I use AI coding tools, mainly Claude Code, to write much of the code. I decide what the app does, review the changes, test on my own phone and the simulator, track down bugs, and decide what ships.

How it works

One ledger. On your phone.

Every screen reads from one store that owns the money, saves it as a file on the phone, and shares balances with the widgets.

On the phoneYour records never leave it
SwiftUI screensHome, Activity, Settings
LedgerStoreThe single source of truth, on the main thread
Ledger fileJSON, written atomically
WidgetsRead balances from an App Group
Scanning a receiptNothing is sent until you agree
Consent screenNames Google Gemini and what is sent
Vercel function8 a minute and 120 a day per address, 1.5 MB cap
Google GeminiReturns items, tax and tip as structured JSON
Bill editorYou check it against the paper
Built, and switched offOne compile-time flag
Shared balancesTwo people, one ledger, over iCloud, with invites and push updates

Decisions

The hardest one was leaving something out.

The hardest call

Ship 1.0 without sync.

I built shared balances over iCloud: invites, confirmed payments between two phones, push updates. But I had never tested it with two real accounts on two real phones, and a money app that syncs wrong is worse than one that doesn’t sync. Turning it on would also have brought Sign in with Apple, which means in-app account deletion and a different privacy disclosure.

So it sits behind one compile-time flag, connections = false, and 1.0 shipped local-only.

Cost: you can’t share a balance with a friend’s phone yet. That’s the first thing I’d build next, after real two-device testing.

Money

Decimal, never Double.

Floating point can’t hold cents exactly. Every amount is a Decimal, rounded to cents only at the edges.

Cost: more careful code everywhere money moves.

Payments

“Oldest” means the date on the card.

A payment pays off the oldest debts first, by the date you see, not when it was typed in. One comparator, used by every payment path.

Cost: rewiring every path to share it.

Receipt photos

Ask before anything leaves.

Apple requires permission before personal data goes to a third-party AI. The consent screen names Google Gemini and says what is sent. It also says Google doesn’t train on the photo, which is only true because the account is on paid billing. If that ever changes, the sentence comes out.

The API token

Assume it’s public.

A token inside an app binary can be read out in seconds. So instead of hiding it, the backend rate-limits every address before checking the token, caps image size, and sits under a budget cap on the billing account.

Testing

Nine harnesses. Real code.

Every harness compiles the app’s shipping source. None of them copies logic out of it.

20,000

random operations per run against the real ledger, across several seeds, checking every balance after each one.

50,000

random bills through the split math, plus a fairness check that an even split lands evenly.

46

checks on recovery paths that only run when something has gone wrong, forced by making the folder read-only.1

Why compile the real code? An earlier harness copied the payment logic by hand. The app changed and the copy didn’t, and the harness reported thousands of failures against an app that was correct. A test that can drift from the code isn’t testing the code.

The others check bills against hand-worked answers and recurring splits against a calendar.

What broke

Bugs I’m glad users never saw.

A $5.10 tax split three ways paid out $0.03.

Found by the split harness

Weighted splits divided before multiplying. That produced a decimal with 39 digits, and Foundation’s intValue returns 0 for those instead of the whole part. Every share floored to zero.

Fix: a safe floor helper used at both sites. 39,948 wrong of 56,000 generated cases, then 0. A second path that overcharged by a cent went from 15,684 to 0.

A printed tip vanished.

Found testing the new scan flow

The backend read “Gratuity 20% 48.50” correctly. The app threw it away, on the assumption that tips are never printed on receipts. A $312.52 test dinner came out $48.50 short.

Fix: keep the gratuity, and cross-check every scan against the receipt’s printed total.

The preview and the saved bill disagreed.

Found testing the new scan flow

The editor’s “Your share” used a third copy of the split math that divided tax evenly, while the saved bill weighted it by what each person ordered. Off by dollars.

Fix: one implementation, used everywhere.

Buttons that did nothing.

Found in testing

In SwiftUI, opening a sheet in the same moment another one closes is silently dropped. Every “dead button” in the app traced back to this.

Fix: remember the intent and open the next screen after the first one finishes closing.

Adding a person wiped an open bill.

Found by instrumenting, after two wrong guesses

A scanned receipt and every tick disappeared. I printed SwiftUI’s own change log (Self._printChanges()) and it showed the bill screen’s identity changing: sheets attached inside a ScrollViewReader were being rebuilt.

Fix: attach them outside it. Lesson: measure before guessing.

An audit that was wrong.

Found by checking it against the real code

An AI-written audit listed seven import and export bugs, with plausible line numbers. When I checked them, almost none were real: the search had read stale copies of old files left in working folders, not the code that ships.

Fix: threw the audit out, and every search now excludes those folders. Lesson: an AI’s finding is a lead to verify, not a fact.

App Review

Rejected. Appealed. Released.

  1. Version 1.0 submittedSix weeks after the first commit.
  2. Rejected under Guideline 5.6.4App quality. My first theory, worked out with an AI assistant, was that the icon looked like another company’s logo. I redesigned it, then found that guideline isn’t about logos at all, and put the icon back.
  3. A clean install told the truthOnboarding’s Continue skipped two of its three pages, one screen could never be reached, and system controls didn’t match the app’s own. All fixed.
  4. Appeal grantedApple’s review board said the issue was addressed and a new build could be submitted.
  5. Build 39 submittedThe fixes in between came from my own testing on the simulator and on my phone.
  6. ReleasedThen iOS 27 redrew parts of the app in Apple’s new glass style, so 1.0.1 is already in testing.

What I learned

What I’d keep, and what I’d change.

Verify before acting.

The icon redesign came from reading a rejection too quickly. Now I check the source before changing anything, including what an AI tool tells me.

Test the real code.

Harnesses that compile the app found bugs no amount of tapping would have. A copied-logic test only found its own drift.

Next: sync, properly.

I’d test shared balances on two devices, then ship them. And adopt Apple’s new design properly instead of opting out of it.

Measure before guessing.

The vanishing-bill bug took two confident wrong guesses before I printed what SwiftUI was actually doing. Now I instrument first.

Back to

All work ›