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
TimelineStarted March 2026. Released October 9, 2026.
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.

The app most people use for this is Splitwise, but its free version only lets you add a few expenses a day, and it shows ads. So I made my own.

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 small backend, and the tests behind them.

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 decide what the app does, work alongside tools like Claude Code, 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 functionRate-limited, with a size 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.

Binary floating point can’t store most cent values exactly (0.1 + 0.2 isn’t 0.3), and the errors add up across a ledger. Every amount is a Decimal, rounded to cents only when it’s displayed or saved.

Cost: more careful code everywhere money moves.

Payments

“Oldest” means the date on the card.

A payment pays off the oldest debts first, judged by the date shown on each entry, not the moment it was typed in. Every place in the code that applies a payment uses the same sorting function, so two screens can never apply one payment differently.

Cost: changing every payment path to go through that one function.

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.

Scanning costs money

Cap the cost.

Every scan is a paid call to Google. So the scanning server limits how often it can be used and how large a photo can be, and the billing account has a hard budget cap underneath both.

Testing

Nine test harnesses. One rule.

A harness is a program that feeds the app thousands of generated cases and checks every result. The rule: each one is built from the app’s own source files, never from a copy of its logic.

20,000

random edits per run against the real ledger. After each one it checks that no money was created or lost and that every bill still matches its entries. Any failure can be replayed exactly from its seed.

50,000

generated bills through the split math, plus a check that an even split really comes out even, not just to the right total.

46

checks on the code that only runs after something has failed, like a save that can’t write. The harness causes those failures on purpose by making the app’s folder read-only.1

Why that rule exists. An older payment test carried its own hand-copied version of the payment logic. When I later changed the real code, the copy stayed the same, and the test reported 19,766 failures in an app that was actually working. It was testing the copy, not the app. Building every harness from the real files means a test can only ever fail because the shipping code is wrong.

The remaining harnesses compare bills against answers worked out by hand, and check that repeating splits land on the dates a calendar says they should.

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. Dividing first produced a Decimal with 39 significant digits, and Foundation’s intValue returns 0 for a number that long instead of its whole part. So every share was rounded down to zero cents, and the leftover pass could only hand back one cent per person.

Fix: one rounding helper that floors correctly, used in both places. 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, if a button closes one sheet and opens another in the same step, the second one is silently dropped, so the tap looks like it did nothing. Every “dead button” in the app traced back to this.

Fix: save what the button asked for, and open the next screen only once the first has finished 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 being thrown away and recreated. The sheet was attached inside a ScrollViewReader, a part of Home that SwiftUI rebuilds whenever the people list changes.

Fix: attach the sheet one level up, outside the part that gets rebuilt. 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. First build on an iPhoneStill called BillSplit, next to a quick web prototype of the same idea.
  2. Version 1.0 submittedAlmost four months after the first build.
  3. 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.
  4. A fresh install showed the real problemsOnboarding’s Continue button skipped two of its three pages, one screen could never be reached, and standard iOS controls clashed with the app’s own style. All fixed.
  5. Appeal grantedApple’s review board said the issue was addressed and a new build could be submitted.
  6. Build 39 submittedThe build Apple approved. The builds before it each fixed something my own testing on the simulator and my phone turned up.
  7. 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.

Tests built from the app’s own files found bugs no amount of tapping would have. The one test that used a copy only ever found its own mistakes.

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 ›