Selected workBupples
Bupples
Group expense splitting with a cent-exact multi-currency ledger and scanned receipts.
- Role
- Sole developer — product design, Flutter client, Firebase backend, security rules, release
- Team
- Solo
- Timeline
- February 2026 – Present
- Status
- Live on the App Store and Google Play
- Category
- Social finance app
- Platforms
- iOS · Android
- Built with
- Flutter / DartRiverpodFirestore + Security RulesCloud Functions (Node 22, TypeScript)Firebase Auth, Storage, Messaging, Remote Config, CrashlyticsVertex AI — Gemini 2.5 FlashGoogle ML Kit text recognition (on device)RevenueCatSwift / SwiftUI / WidgetKitKotlin / Android RemoteViewsFirebase Hosting
01/EXECUTIVE SUMMARY
What it is.
Bupples is a social expense app for the people who actually have to settle up afterwards — housemates, trip groups, a table of friends splitting one receipt. A group creates a hangout, adds expenses, photographs the bill, taps the items each person had, and the app tracks who owes whom until the debts are cleared. I built it as sole developer: product design, the Flutter client, the Firestore data model and security rules, ninety-nine Cloud Functions, the receipt-reading pipeline, the subscription server, the native iOS and Android widgets, and the release tooling. The engineering that matters is the money. Every amount is stored as an integer number of minor units and every split runs through one shared kernel, so a remainder always lands somewhere deterministic instead of disappearing between two screens. It is live on the App Store and Google Play.
02/THE PROBLEM
What was broken.
Splitting a bill is arithmetic. Settling one is a negotiation, and the arithmetic is what people argue about.
- [object Object]
- [object Object]
- [object Object]
- [object Object]
03/ROLE & OWNERSHIP
What I owned.
What I designed and engineered
- Product design and the whole interface, including the design system it is built from
- The Flutter client: 656 Dart files under lib/, feature-first, with the money kernel (cents.dart, ledger.dart, money.dart) free of any Flutter import and the wider domain layer limited to flutter/foundation, so the money maths is tested without a widget tree
- The money domain — parsing, splitting, the pairwise debt graph, settlement, multi-currency
- The Firestore data model, 2,254 lines of Security Rules and the rules-emulator suite
- 99 Cloud Function handlers in TypeScript: callables, document triggers, scheduled jobs and the billing webhook
- The receipt pipeline end to end — on-device OCR, cloud extraction, reconciliation, claiming, finalisation
- The subscription and entitlement server, including gifting, lapse enforcement and grace handling
- Native platform work: an AVFoundation QR scanner, a UITabBar platform view, and WidgetKit and RemoteViews widget extensions
- Release engineering — signing, build-freshness verification, deploy runbooks and store submission
- The hosted product site, its legal pages, and the browser claim flow at /t
What I did not own
- The managed platform. Firebase supplies authentication, the realtime document layer, storage, messaging and the functions runtime. I designed on top of it; I did not build it.
- The models. Extraction quality comes from Gemini 2.5 Flash on Vertex AI and Google's on-device ML Kit recogniser. Mine is the pipeline around them: the quality gates, the prompts, the confidence threshold and the arithmetic that checks their output.
- Store billing. RevenueCat handles receipt validation and subscription state with Apple and Google; I own the entitlement model on my side of the webhook.
- Typefaces and icons. Fraunces, Hanken Grotesk and JetBrains Mono are bundled third-party variable fonts and the icon set is Hugeicons.
04/CONSTRAINTS
What shaped the build.
One developer, no reviewer
Every layer is mine, which means nothing is caught by someone else reading it. The response was to write the rules down rather than remember them: standing engineering rules, a design contract that locks the type, radius, shadow and motion ladders, and five performance budgets that block a release build. Rules in a file outlast the memory of why they existed.
Money cannot be eventually correct
A social app can tolerate a stale avatar. A ledger cannot tolerate a stale balance, and it certainly cannot tolerate a payment that applies twice or not at all. This ruled out floating point, ruled out client-authored balances, and made idempotency a requirement of every operation that moves money rather than a refinement.
The iOS build stays pure Swift Package Manager
No Podfile. Two plugins had already broken this build the same way, so any dependency that drags CocoaPods back in is a dependency that has to justify itself or be written by hand instead. It is a self-imposed constraint that costs native code and buys a build that keeps working.
Every scan has a marginal cost
The app is free with an optional subscription, and a cloud model call is billed per scan. A pipeline that always runs its most expensive read is a pipeline whose feature eventually gets switched off to save money. Accuracy had to be earned per receipt, not paid for on all of them.
06/PRODUCT FLOW
How it works.
-
Photograph
Blur and contrast gates reject an unreadable page before a model is ever paid for it. At most four pages, 1600 px on the long edge.
-
Read on device
ML Kit returns real text with real bounding boxes in tens of milliseconds, so the progress the user sees is genuine rather than an animated guess.
-
Extract in the cloud
A fast Gemini pass structures the page into line items, tax, service charge and totals. Its 0–1000 boxes are normalised into one 0–1 coordinate contract shared with the on-device pass.
-
Earn the second pass
Below a 0.85 trust score, only the uncertain lines are re-read — zoomed crop, higher media resolution, an automatic thinking budget.
-
Reconcile
Line arithmetic is checked against the printed totals using named, bounded tolerances including per-currency cash-rounding steps. Anything the checks cannot explain surfaces as a visible residual.
-
Claim
Each member taps the items they had. The Dart preview and the TypeScript writer share the same invariants, so the share shown while tapping is the share that gets stored.
-
Finalise
A callable writes the money server-side inside a transaction, allocating tax and service per item, and the group's balances move.
-
Settle
Pair-level settle-up always reads the direct ledger. The fewest-payments plan is a separate view, and a payment recorded against it carries the debts it actually covers.
07/SYSTEM ARCHITECTURE
How it fits together.
Flutter client — iOS and Android
- 24 features, four layers each: domain / data / application / presentation
- Riverpod providers and controllers
- Money domain — parsing, split kernel, pairwise debt graph, settlement
- Firestore offline persistence; writes queue and sync on reconnect
- Remote-control gate — maintenance, force-update and degraded states
Native surfaces
- AVFoundation QR scanner (platform channel)
- UITabBar embedded as a Flutter platform view
- WidgetKit extension — home, lock screen, StandBy
- Android RemoteViews widget providers
- App Group group.com.bupples.widgets
Firebase — managed
- Auth — Apple, Google, email
- Firestore — 25 top-level collections, 30 nested rule blocks
- Security Rules — 2,254 lines, 26 helpers
- 16 composite indexes
- Storage, Cloud Messaging, Crashlytics, Analytics
- Remote Config — 15 parameters
Cloud Functions — Node 22, TypeScript
- 58 callables — joins, settlements, receipts, ownership, entitlements
- 27 Firestore document triggers
- 13 scheduled jobs — auto-confirm, reminders, sweeps, rollovers
- balance.ts — server mirror of the pairwise ledger
- receipt_math.ts — exact claim arithmetic in BigInt
Receipt extraction
- ML Kit text recognition — on device, first pass
- Vertex AI · Gemini 2.5 Flash — fast structured pass
- Vertex AI — accurate pass: thinking budget, zoomed crop
- Merchant layout templates learned from repeat observations
Subscriptions
- RevenueCat SDK on device
- revenueCatWebhook — shared secret from Secret Manager
- customers/{uid} entitlement document
- Scheduled lapse, grace, trial and win-back jobs
Firebase Hosting
- Static product site and legal pages
- /t — Turbo split claim in the browser, no install
- /j, /te, /@ — deep-link landings
- Universal Links and App Links
- client nativePlatform channels; widget data through an App Group
- client extractionOn-device OCR runs before the network call
- client firebaseDirect reads and writes, every one checked by rules
- firebase clientRealtime listeners, budgeted at ≤4 per joined hangout
- client functionsCallables for anything money- or membership-critical
- functions firebaseAdmin SDK writes: joins, settlements, finalised receipts, ownership
- functions extractionVertex AI with Application Default Credentials — no API key
- client billingPurchase and restore through the store
- billing functionsEntitlement webhook, authenticated with a Secret Manager secret
- functions billingLive entitlement re-read before any privileged grant
- web functionsThe browser claim page calls the same callables
- web firebaseAnonymous sign-in, then the same rules apply
08/ENGINEERING CHALLENGES
The hard parts.
Cent-exact money, and exactly one place that divides it
Why it was hard
A split is a division that rarely comes out even. Three people on a ten-ringgit bill, four on a bill ending in a stray cent, percentage splits, share-weighted splits, a bill two people paid at once. Every one produces a remainder, and every feature that handled its own remainder was a chance for the expense screen and the balance screen to disagree by a cent — and for a group's balances to stop summing to zero. Parsing was its own hazard before any division happened: multiplying a decimal by a hundred and rounding drifts in binary floating point, which turns 0.35 into 34 cents and 12.005 into 1200.
Options considered
- Store amounts as doubles and round when displaying them — the obvious version, and the one whose specific failures are written into the file as the reason it was abandoned
- Let each feature round its own way, which is what produces the two-screens-disagree bug
- Integer minor units everywhere, with one shared kernel that every split calls
What I built
Money is integer minor units end to end. Parsing does string-based integer arithmetic rather than touching a float at all, and is exponent-aware for currencies with zero, two or three decimal places. One file is the only place cents are distributed: a proportional distributor that gives leftover cents to the largest weights first, and an even splitter that gives them to the first sorted ids. Its own header says why it exists — one implementation shared by the expense split, the multi-payer who-paid editor and the settlement surplus maths, so no two call sites can drift. Expense shares return cents per participant for equal, exact, percentage and share methods and always sum back to the total; custom splits with some members pinned use largest-remainder rounding for the rest. Currencies are never added together: the ledger is keyed by currency and within each one the members' balances sum to zero.
What it cost
A shared kernel means no feature gets to invent a local rounding rule for the sake of a nicer-looking number. And the money format contract is not automatically shared across runtimes — it has hand-maintained mirrors in the Cloud Functions copy, the iOS widget and the Android widget provider, so a change to one has to land in all four.
How I know it works
The invariants are asserted rather than assumed: shares always sum to the expense total, and within each currency the members' net balances sum to zero. The rule is written into the repository's standing rules as law, so a later feature cannot quietly reintroduce a float on an amount.
Result
Balances agree across every screen and every device, and a rounding remainder always lands somewhere deterministic instead of vanishing. Multi-currency groups work without an exchange-rate feed, because nothing is ever converted.
A fewest-payments plan that could lose money
Why it was hard
There are two ledgers, not one. The direct ledger says who owes whom. The simplified plan solves for the fewest transfers — at most one less than the number of people — and to do that it reroutes money: it asks A to pay C when A never owed C, because A owed B and B owed C. When someone recorded a payment against a rerouted row, the ledger went looking for a direct A-to-C debt, found none, clamped the reduction to zero, and the payment simply disappeared. Both sides still showed the debt and the payer was invited to pay it again. It is the worst class of bug an expense app can have, because the app keeps insisting and the user knows they already paid.
Options considered
- Delete the fewest-payments plan and show only direct debts — which loses the feature that makes settling a six-person trip bearable
- Record the routed payment as a direct credit from payer to receiver — which invents a debt that never existed and breaks the zero-sum
- Decompose the routed payment into a flow through the debt graph at the moment it is recorded, and store the edges it covers on the payment itself
What I built
The third. When a payment is recorded against a rerouted row, it is decomposed into a flow through the owes graph and the covered edges are stored on the request; the ledger then reduces each of those real edges instead of hunting for a pair that does not exist. The soundness argument is per-node conservation: in a flow, every intermediate node's inflow equals its outflow, so only the payer's and the receiver's net positions move and nobody else's balance is touched. The existing per-edge clamp still applies on top, which bounds the failure mode — a stale or hostile decomposition can only ever under-apply, never manufacture a credit. The ledger validates the stored flow before trusting it and falls back to the old direct clamp if validation fails. The server keeps its own copy of the same computation, with a comment requiring the two apply loops stay line-for-line comparable.
What it cost
The payment record now carries derived data that can go stale if the underlying bills are edited afterwards. That is bounded rather than solved: validation plus the per-edge clamp means a stale decomposition under-applies and the balance stays owed — recoverable — instead of over-applying, which is not. It also adds a second implementation of the same arithmetic to keep in step.
How I know it works
A dedicated backend unit test covers the routed case. Under the repository's standing test doctrine, a behavioural fix does not count until its test has been verified red against the pre-fix code by reverting the fix and re-running it.
Result
Shipped in the 1.3.0 programme under financial and state integrity. The rule that came out of it is now written down: pair-level balance and settle-up UI always reads the direct ledger, and the simplified solver is only ever the fewest-payments plan view.
Receipt claiming has to agree to the sen, in two languages
Why it was hard
When a table splits a scanned receipt, each person taps the items they had and sees their share update immediately. The preview is computed on the phone in Dart; the money is written by a Cloud Function in TypeScript. Two implementations of the same arithmetic in two runtimes, and if they disagree by one unit the receipt shows one number and the ledger stores another. The arithmetic itself is not simple either: tax and service charge have to ride per item so an item only carries its own proportional slice, items can be claimed by more people than ordered them, some items are claimed by nobody and have to stay in a pot, and real bills do not add up — Malaysia retired the one sen coin, so a cash total rounds to the nearest five sen and prints the adjustment, or on plenty of tills does not print it at all.
Options considered
- Compute everything server-side and drop the live preview — which means tapping an item and waiting for a round trip
- Use floating-point percentages and reconcile with a tolerance at the end — a general fuzz factor is exactly how a real thirty-sen error gets absorbed and shipped as truth
- Exact rational arithmetic over integers, implemented twice against one written contract, with the same invariants written down once and property-tested on the Dart side, with a deterministic pseudo-random sweep over the tax-and-service allocation on the TypeScript side
What I built
Both implementations work in integer minor units and use exact rational arithmetic over big integers, so rounding is deterministic in both runtimes rather than dependent on either one's float behaviour. The invariants are named in both files and hold for any input: member subtotals plus the unclaimed pot equal the item total exactly, with over-claims clamped first; tax and service allocated per member plus the unallocated remainder equal tax plus service exactly; splitting the remainder equally conserves the pot exactly. Cash rounding is a per-currency table of named steps rather than a tolerance, and the reconciler's stated design rule is that tolerances are named, bounded and enumerated — anything the checks cannot explain comes out as an explicit residual for a human to resolve, never as a silent adjustment.
What it cost
Roughly sixteen hundred lines of the same arithmetic exist twice and must change together. Nothing in the toolchain enforces that; only the tests, the shared invariant list and a comment at the top of each file do.
How I know it works
The same invariants written down once and property-tested on the Dart side, with a deterministic pseudo-random sweep over the tax-and-service allocation on the TypeScript side, plus targeted unit tests for reconciliation and handwritten tip inference.
Result
The share a person watches change while they tap items is the share the ledger stores. A receipt whose printed total genuinely disagrees with its own lines surfaces that as a residual instead of quietly absorbing it into someone's balance.
The trust boundary: the client can ask, the server decides
Why it was hard
Every figure in the app derives from documents that phones write. The natural Firestore shape lets a member edit their own membership row — and since money is keyed to membership, editing membership edits money. Session ids that can be listed leak private groups. And the operations that matter arrive over mobile networks where a retry, a double tap or a resumed background write is normal, so an operation that applies twice is not a rare case to design against later.
Options considered
- Trust the client and reconcile afterwards — an expense app cannot reconcile a hostile write after the fact
- Route every write through Cloud Functions — which gives up the realtime listeners that make shared balances feel live
- Rules that bind every write to the caller's uid, plus server-only fields clients cannot touch at all, plus idempotent callables for the operations that must not double-apply
What I built
The third, drawn as an explicit boundary. Authorisation binds to the Firebase auth uid and never to a client-supplied member id. Sessions cannot be listed — a session is readable only through an unguessable id reached via a join-code lookup. Joining goes through a Cloud Function using the Admin SDK, so a client can never grow the authorised-uid list itself; a subset guard prevents the self-leave branch being used to evict the host or swap in another uid. Expenses are owner-scoped: the creator or the host may edit, and everyone else files a change request. The host field is server-only, and ownership moves only through a callable so the hand-off is audited and notified. Paid cosmetic frames are denied to clients outright in the rules; the only route onto one is a callable that re-reads the live entitlement first. On the server, transactions carry the write-critical paths and idempotency is committed inside the transaction rather than checked before it — a double-tapped claim returns an 'already done' result instead of applying twice.
What it cost
The rules file is a second description of the domain's shape. Add a field to an expense and it has to be added to the rules' payload validator too, or the write is rejected in production and nowhere else. That cost is paid down with an emulator suite rather than with looser rules.
How I know it works
25 Firestore rules test files run against the emulator alongside 45 backend unit test files. I also ran an eight-domain static adversarial review of my own shipped code at version 1.1.1 in July 2026; it scored the app five out of ten at that point and produced a register of findings.
Result
The audit's highest finding — a member could rewrite the authorised-uid list through the self-leave branch and evict the host — is closed in the current rules, with the attack it prevents named in the comment above the guard. The register is not fully closed, and what is still open is stated in the lessons below rather than left out.
Reading a receipt twice, and only paying for the second read when it is worth it
Why it was hard
A photographed receipt is skewed, creased, thermally faded, often bilingual, and sometimes has a tip written on it in pen. One model call at maximum quality reads it well and costs money and seconds on every single scan, including the clean ones. A cheap call is fast and wrong exactly where it matters — the line with the price on it. And a progress indicator that sits still for eight seconds reads as a broken app, whatever it is doing underneath.
Options considered
- Cloud extraction on every scan at full quality — slow first frame, and a per-scan bill on a free app
- On-device OCR only — it gives text and boxes, but not structure: which line is an item, which is service charge, which is the total
- Two tiers with a confidence gate, and an on-device pass in front of both so progress is real
What I built
ML Kit runs on the device first and returns real text with real bounding boxes in tens of milliseconds, before the cloud call has even finished its handshake — which is what makes honest progressive processing possible instead of a fake progress bar. Then a fast cloud pass with thinking switched off structures the page. If its trust score clears 0.85 the scan is finished. Below that, a second pass earns its cost: an automatic thinking budget, higher media resolution, and a zoomed crop of only the uncertain lines rather than the whole page again. The whole thing is bounded — per-call deadlines of 30 and 25 seconds inside a 60-second callable timeout, four pages maximum, a 1600-pixel long edge, and blur and contrast gates that reject a photograph before it is ever sent. The model's coordinates come back in a 0–1000 space and are normalised into one shared 0–1 contract, with out-of-order coordinates sorted rather than dropped. Merchants whose layouts recur build a template that hints the prompt.
What it cost
Two prompt paths and two failure paths to maintain. And the accurate tier currently points at the same model as the fast one, pending a stronger one being provisioned, so today the second pass buys thinking budget and a zoomed crop rather than a larger model.
How I know it works
Extraction is not trusted on its own: the lines land in the reconciler, which checks the line arithmetic against the printed totals and refuses to absorb an unexplained difference. Scanning is also behind a Remote Config flag, so it can be switched off in production without shipping a build.
Result
Real text appears on screen within about a second of the shutter, and the expensive read is paid for only on the receipts that need it — which is what keeps the feature switched on.
09/TECHNICAL DECISIONS
Why this way.
Entitlements are server truth
Chose RevenueCat webhooks into a server-held entitlement, re-read before any privileged action
A purchase state the client reports is a purchase state the client can be persuaded to lie about, and the paid cosmetics are the visible reward, so they are the obvious target. The rules deny a client-written frame id outright; the only route onto one is a callable that reads the live entitlement document first. The webhook is authenticated with a shared secret held in Secret Manager. Scheduled jobs then handle the parts a purchase SDK does not: lapse enforcement, expiring perks, grace status, trial reminders and gifting between subscribers.
Instead of Trusting the purchase SDK's cached entitlement on the device
Trade-off Entitlement changes take a network round trip and depend on a webhook being reachable, so a subscriber who upgrades in a tunnel sees their perks a moment after the server hears about it.
A pure Swift Package Manager iOS build, and native code where a plugin would break it
Chose Writing the camera scanner and the tab bar natively rather than adding plugins
Two plugins had already broken this build the same way, so the QR scanner is a hand-written AVFoundation platform channel instead of a dependency. The tab bar follows the same reasoning from the other direction: iOS 26 draws its own material, so the app embeds a genuine UITabBar as a platform view and lets the operating system draw it, rather than imitating the material in Dart and getting it subtly wrong on every OS update.
Instead of Adding the published plugin and accepting a Podfile
Trade-off Native code I own on two platforms and have to maintain across OS releases. One dependency had to come back regardless — the on-device OCR is CocoaPods-based — so it ships with a written three-step rollback in case it stops surviving an iOS build.
A country-neutral core with regional adapters
Chose Treating Malaysian behaviour as one adapter among many rather than the default
The app was built in Malaysia and its first bills were in ringgit, which is precisely how a currency becomes a hardcoded fallback and a group in Singapore ends up looking at RM. Region resolution has a documented provenance ladder, currency and cash-rounding behaviour are per-currency tables, and the standing rule forbids hardcoding the local currency as a fallback in new code. Multi-currency then falls out of the same discipline: each currency is its own independent ledger and nothing is ever converted, because a wrong exchange rate inside a shared bill is worse than no exchange rate at all.
Instead of Shipping the local currency as the default and generalising later
Trade-off Every money surface has to carry a currency and know its exponent, and the display contract now has four hand-maintained mirrors — the Dart formatter, the Cloud Functions copy, the iOS widget and the Android widget provider — which have to change together.
Timezone names, never offsets
Chose Persisting the user's IANA zone name and resolving the local hour on the server at send time
An offset is only correct until daylight saving moves. A London account that reported +0 in February had its nine o'clock reminder land at ten for the whole of March. A zone name resolves the exact local hour on Node, which has full ICU data, at the moment the job runs.
Instead of Sending the device's UTC offset along with the preferred time
Trade-off A platform dependency on the client purely to read the zone name, and a set of DST-boundary tests that have to be kept honest.
Cloud Functions are deployed by name
Chose Naming every function in the deploy command and keeping the order in a runbook
There are 99 handlers in one codebase. A bare functions deploy treats the deployed set as something to reconcile against the local build, so anything the build does not emit that day is removed — on a backend that moves money, that is a live outage rather than a tidy-up. The rule is written into the repository's standing rules; rules and index deploys are exempt because they are whole-file replacements anyway. Release steps live in scripts that verify build freshness and signing configuration rather than in someone's memory.
Instead of The one-line deploy that every Firebase tutorial teaches
Trade-off Long deploy commands, and a new function has to be named explicitly or it silently never ships. The checklist trades a loud failure for a quiet one, so the runbook has to list the order.
10/DESIGN EVOLUTION
How it changed.
First release changed the failure mode
The app went public on the App Store on 2 July 2026 and on Google Play, and from that point a bug stopped being a hot reload and became a release. Build numbers are bumped per shipped cut rather than per commit, and the cadence since has been steady: 1.1.0 in mid-July, 1.1.1 the week after, 1.2.0, then 1.2.5 in the store on 27 July 2026, with 1.3.0 in the working tree.
Audited my own shipped code, then fixed what it found
At version 1.1.1 I ran an eight-domain static adversarial review of the live code rather than against the design. It scored five out of ten and produced a register. The highest finding was a membership-privilege escape through the self-leave branch; the current rules carry the subset guard that closes it, with the attack it prevents written above the guard so the reason survives the next edit.
A performance rework driven by measurement, not by feel
Eleven read-only per-domain audits produced three named root causes rather than a list of impressions: a dead wake gate that diffed around fifty widgets per framed bubble per frame at up to 120 Hz while the screen was idle, a cached still image that never re-armed, and photo decodes keyed on an animating radius — roughly forty full decodes per photo bubble per ease, fixed by quantising the radius into buckets. The same pass found seven provider families leaking Firestore listeners and the settlement solver inlined in three providers, re-running on every unrelated document change.
Budgets that block a build, not guidelines
The performance work ended in five written budgets rather than a fixed list: one shared background-ambience clock; fixed-rate bubble physics with vsync-interpolated presentation and a cached still when idle; frame-time ceilings measured on a mid-range Android baseline rather than on my own phone; at most four app-lifetime listeners per joined hangout with route-scoped listeners released within thirty seconds; and thermal discipline, where a self-heating audit lowers that device's ambience ceiling with a fourteen-day expiry so one hot session never caps a device forever.
11/TESTING & RELIABILITY
How it is proven.
Tests here exist to fail first. The repository's standing rule is that a behavioural fix does not count as done until its test has been verified red against the pre-fix code — by reverting the fix and re-running it. A test written after a fix and green on the first run proves nothing about the bug it claims to cover.
- Dart test files
- 558
- Dart test declarations
- 4,812
- Grouped suites
- 895
- Backend test files
- 25 rules + 45 unit
- Backend test declarations
- 965
- On-device journeys
- 3
Representative cases
- [object Object]
- [object Object]
- [object Object]
- [object Object]
12/RESULTS
What shipped.
- Shipped, on both stores
- Live on the App Store as a Finance app, free, minimum iOS 15.5, first released 2 July 2026, and on Google Play under com.bupples.bupples. Universal Links and App Links are configured, so a join code, a Turbo split or a profile handle opens the installed app and falls back to the web page when it is not installed.
- The scope one person carried
- 656 Dart files and around 250,000 lines under lib/, roughly 30,000 lines of TypeScript in the functions codebase, 99 deployed Cloud Function handlers, 25 top-level Firestore collections with 30 nested rule blocks, 2,254 lines of security rules and 16 composite indexes. Twenty-four features, each with its own domain, data, application and presentation layers, and a money kernel with no Flutter import at all, so the money maths can be tested without a widget tree.
- Operational control without a store release
- Fifteen Remote Config parameters gate the assistant, receipt scanning, nudges, the paywall, maintenance mode, a version gate and update prompts, with a client-side gate that renders maintenance, force-update and degraded states. An expensive or misbehaving feature can be switched off in minutes rather than in a review cycle.
- What is not claimed here
- There are no download, revenue, rating or retention figures in this case study. The store listings are public and checkable; the numbers behind them are not something I can evidence, so they are left out rather than rounded into a sentence.
13/REFLECTION
What I learned.
What worked
- One kernel, called from everywhere. The single most valuable file in the project is 46 lines that distribute leftover cents. Everything that divides money calls it, so there is exactly one place a rounding decision can be wrong — and exactly one place to fix it.
- Writing the law down. Money is integer minor units. Pair-level settle-up uses the direct ledger. Never hardcode the local currency as a fallback. These live in a rules file, not in my memory, and they are the reason a feature written six weeks later still behaves.
- Red first. Reverting the fix and watching the new test fail is slower than writing the test after, and it is the only version that proves the test covers the bug rather than the code.
What I underestimated
- The fewest-payments plan. I shipped it believing it was a presentation of the direct ledger. It is a different ledger, and a payment recorded against it could vanish. The lesson was not about greedy algorithms — it was that any view which reroutes money is not a view.
- The cost of a mirror. The same arithmetic exists in Dart and in TypeScript, and in four places for money formatting. Nothing in the toolchain enforces that they change together; only tests, comments and discipline do. It works today and it is the standing hazard in the codebase.
- How much of the work is not the feature. Security rules, idempotency, kill switches, deploy runbooks, signing scripts, DST handling. The visible product is a fraction of what shipping it required, and none of that fraction was optional.
What I would do next
- App Check enforcement. The client carries the App Check SDK, but server-side enforcement across the callables sits behind a single constant that is still false in source. Rules already bind every write to the caller's uid; attestation is the layer on top, and turning it on is the next piece of work rather than something already claimed.
- The rest of the audit register. I verified that the highest finding is closed in the current rules. The remaining findings need to be re-checked one by one against the code as it stands now, and recorded, instead of assumed to have been swept up by later work.
- One money implementation, not two. The Dart and TypeScript copies should be generated from a shared specification, or one of them should stop existing, so drift becomes impossible rather than merely tested against.