Selected workBupples
Bupples
Shared expenses, scanned receipts and clear settle-up plans for friends.
I designed, built and released an expense-sharing app for iOS and Android, from the multi-currency ledger to receipt review and the Firebase backend.
See outcomes
- Role
- Sole developer — product design, Flutter client, Firebase backend, security rules, release
- Team
- Solo
- Timeline
- February 2026 – Present
- Status
- Live on iOS and Android
- Category
- Social finance app
- Platforms
- iOS · Android
- Built with
- Flutter / DartRiverpodFirestore + Security RulesCloud Functions (Node 22, TypeScript)Firebase Auth, Storage, Messaging, Remote Config, Crashlytics, AnalyticsVertex AI — Gemini 2.5 FlashGoogle ML Kit text recognition (on device)RevenueCatSwift / SwiftUI / WidgetKitKotlin / Android RemoteViewsFirebase Hosting
01/EXECUTIVE SUMMARY
The short version
Bupples helps friends split dinners, trips and everyday costs. Create a hangout or a standalone split, scan a receipt, choose who had each item, and track what remains to settle. I own the product design, Flutter app, Firebase backend, native widgets and releases. The central engineering problem is trust: every split must preserve the total, every retry must avoid duplicate money records, and private expenses must stay with their participants. The app is live on iOS and Android.
The product problem
Friends need a clear way to share real bills: different items, multiple currencies and payments made at different times. Bupples brings the receipt, each person’s share and the settle-up plan into one flow.
Evidence notes
See The problem and Product flow below for item claims, currency handling and settlement decisions.
What I delivered
Product design, a Flutter app for iOS and Android, a Firebase backend, receipt extraction with review, subscriptions, native widgets and store releases. One developer responsible for the product from interface to release.
Evidence notes
See Role & ownership, the technology stack and the App Store and Google Play listings linked above.
Proof you can inspect
Open the live store listings, follow the architecture below, and read the release verification record. The build 184 record reports 6,048 Flutter tests passed and 91 native fixture checks per platform.
Evidence notes
Release verification, 5 September 2026: docs/releases/1.4.6_build_184_verification.md. Counts describe that release; current releases may differ.
02/THE PROBLEM
What was broken.
Splitting a bill is arithmetic. Settling one is a negotiation, and the arithmetic is what people argue about.
- The number has to be identical on every phone — A group expense is edited by several people at once, from several devices, some of them offline. If two screens disagree by a single cent, the argument is no longer about the bill — it is about whether the app can be trusted at all. That rules out anything that rounds at the display layer.
- Real receipts do not split evenly — Equal splits are the easy case. The real one is a table where three people shared a starter, one person drank, tax and service charge sit at the bottom as percentages of everything above them, and the printed total has been rounded to the nearest five sen because the country retired its smallest coin.
- Most apps stop at the number — Knowing you owe someone is not settling with them. The app has to hold a debt through a partial payment, a payment the other person has not confirmed yet, a payment routed through a third person by a fewest-transfers plan, and a bill that gets edited after the money has already moved.
- Nobody wants to type a receipt in — Itemised splitting only works if getting the items in is faster than doing the arithmetic in your head. That means reading a creased thermal receipt from a photograph — and doing it fast enough that the person holding the phone does not put it down.
03/ROLE & OWNERSHIP
Mine, and not mine
What I designed and engineered
- Product design, the Flutter interface and its shared components
- The money domain: integer minor units, split allocation, direct debts, routed settlements and per-currency ledgers
- The Firestore data model, Security Rules and rules-emulator coverage
- Cloud Functions for membership, receipts, payments, subscriptions, notifications and account lifecycle
- The receipt pipeline: on-device OCR, cloud extraction, reconciliation, manual review and finalisation
- Standalone expense invitations and participant-scoped private expense flows
- RevenueCat entitlement handling, native iOS and Android widgets, and typed Firebase Analytics events
- Release verification, signing, store submission and the hosted product site
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
The boundaries it was built inside
One developer across the whole product
Client, backend and release work share one owner. Written money invariants, shared interface components and regression tests keep a fix in one screen from quietly changing another. Release checks verify the packaged version and source fingerprint as well as whether a build succeeds.
Evidence notes
Bupples source inspected 2026-09-05, pubspec.yaml 1.4.6+184; docs/releases/1.4.6_build_184_verification.md and 1.4.6_whats_new.md.
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.
Evidence notes
functions/src/balance.ts header: the persisted server-side preference "and ONLY that value" decides what contributes; "anything a client sends … is NEVER read for financial computation". 69 runTransaction call sites across functions/src, re-derived NUL-safely 2026-07-29: grep -rao "runTransaction(" functions/src | wc -l -> 69 (the bare identifier appears 77 times including comments). -a matters: functions/src/balance.ts contains NUL bytes, so a plain grep treats it as binary and silently skips it.
Native dependencies have to survive real builds
The app combines Flutter with native widgets, camera integration and on-device OCR. ML Kit requires CocoaPods and special handling for Apple Silicon simulators. The release checks verify that simulator preparation does not alter the ML Kit binaries shipped to devices.
Evidence notes
ios/Podfile; docs/releases/1.4.6_build_184_verification.md: simulator helper, restoration of device binaries, iOS and Android fixture verification.
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.
Evidence notes
functions/src/receipt_scan.ts SCAN_FALLBACK_ITEM_CONFIDENCE 0.7, whose comment says paying for a second call on every scan "is how a feature gets switched off" (receipt_scan.ts:112-118). SCAN_TRUST_THRESHOLD 0.85 at line 110 carries only "Field confidence at or above this reads cleanly.".
05/PRODUCT FLOW
The path through it
-
Capture
Photograph a bill, adding overlapping images when a long receipt needs more than one scan.
-
Read
On-device ML Kit reads the text; a Gemini pass structures items, quantities, discounts, tax and totals.
-
Check
Reconcile the items against the printed total. Uncertain lines and unexplained differences stay visible for review.
-
Review
Correct items and choose whether discounts are already included or still need applying. Edits survive moving between steps.
-
Split
Add people and allocate items. The Dart preview and TypeScript writer use integer arithmetic and shared conservation rules.
-
Settle
Record and confirm payments, with history explaining who paid, what changed and which debts the payment covers.
06/SYSTEM ARCHITECTURE
How it fits together.
The Flutter iOS and Android app uses Riverpod, a shared integer-money domain and Firebase. Native camera and widget integrations sit alongside it. Firestore Security Rules protect client reads and writes; privileged Cloud Functions enforce money, membership and entitlement rules. ML Kit reads receipt text on the device and Gemini structures it in the cloud. RevenueCat reports subscription changes to the backend. Firebase Hosting serves the product site and browser receipt claims. Typed analytics events measure product flows without including receipt contents or money amounts.
On the device
Flutter client — iOS and Android
- Feature-first domain, data, application and presentation layers
- 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)
- Native platform integration alongside shared Flutter navigation
- WidgetKit extension — home, lock screen, StandBy
- Android RemoteViews widget providers
- App Group group.com.bupples.widgets
On my infrastructure
Firebase — managed
- Auth — Apple, Google, email
- Firestore — shared expenses, memberships and private records
- Security Rules — participant and owner access checks
- 21 composite indexes in the current source
- Storage, Cloud Messaging, Crashlytics, Analytics
- Remote Config — release announcements and feature controls
Cloud Functions — Node 22, TypeScript
- 119 exported function handlers in the current source
- Callables — membership, settlements, receipts, entitlements
- Document triggers and scheduled background jobs
- 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
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
Outside the system
Subscriptions
- RevenueCat SDK on device
- revenueCatWebhook — shared secret from Secret Manager
- customers/{uid} entitlement document
- Scheduled lapse, grace, trial and win-back jobs
- Flutter client — iOS and Android→Native surfacesPlatform channels; widget data through an App Group
- Flutter client — iOS and Android→Receipt extractionOn-device OCR runs before the network call
- Flutter client — iOS and Android→Firebase — managedDirect reads and writes, every one checked by rules
- Firebase — managed→Flutter client — iOS and AndroidRealtime listeners and membership projections
- Flutter client — iOS and Android→Cloud Functions — Node 22, TypeScriptCallables for anything money- or membership-critical
- Cloud Functions — Node 22, TypeScript→Firebase — managedAdmin SDK writes: joins, settlements, finalised receipts, ownership
- Cloud Functions — Node 22, TypeScript→Receipt extractionVertex AI with Application Default Credentials — no API key
- Flutter client — iOS and Android→SubscriptionsPurchase and restore through the store
- Subscriptions→Cloud Functions — Node 22, TypeScriptEntitlement webhook, authenticated with a Secret Manager secret
- Cloud Functions — Node 22, TypeScript→SubscriptionsLive entitlement re-read before any privileged grant
- Firebase Hosting→Cloud Functions — Node 22, TypeScriptThe browser claim page calls the same callables
- Firebase Hosting→Firebase — managedAnonymous sign-in, then the same rules apply
07/ENGINEERING CHALLENGES
Where it got difficult
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.
Where to look notes
lib/core/utils/money.dart (Money.parseToCents string arithmetic, with the failing float cases named in-comment; scaleFor for ISO exponents); lib/features/settlement/domain/cents.dart (distributeCents / splitEvenCents, header quoted above); lib/features/session/domain/session_expense.dart:266 shares(); lib/features/session/domain/custom_split.dart; ledger.dart _byCurrency and netBalancesByCurrency; docs/APP_OVERVIEW.md §1 "there is no FX conversion"; CLAUDE.md money law.
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.
Where to look notes
lib/features/settlement/domain/simplified_decomposition.dart (226 lines; the comment states the defect and the conservation argument); lib/features/settlement/domain/ledger.dart _pairwiseOwes and validCoveredEdgeFlow; functions/src/balance.ts pairwiseOwes ("EXACT mirror … keep the two apply loops line-for-line comparable"); functions/test-unit/routed_settlement.test.mjs; docs/releases/1.3.0_program_report.md, "Financial and state integrity (Priority 0)"; docs/audits/perf-rework-2026-07/COMPLETION_REPORT.md for the red-first doctrine.
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.
Where to look notes
functions/src/receipt_math.ts (1,000 lines) and lib/features/session/domain/receipt_claims.dart (615 lines), whose headers state the shared contract — receipt_math.ts says verbatim "Invariants (property-tested on the Dart side)", and on the TypeScript side only allocate_tax_service.test.mjs runs a sweep ("Deterministic pseudo-random sweep (mulberry32)"), the other two suites being example-based; CASH_ROUNDING_STEPS at receipt_math.ts:673 and the "no general fuzz factor" design rule; functions/test-unit/receipt_reconcile.test.mjs, allocate_tax_service.test.mjs, tip_inference.test.mjs.
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
The current tree contains 29 Firestore rules test files and 64 backend unit test files. The build 184 verification record reports 456 rules checks and 803 backend unit checks passed. The July security audit remains a historical record, not a fresh security certification.
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.
Where to look notes
firestore.rules — 2,254 lines / 128 KB, 26 helpers; header comment lines 3–25 for the authorisation model; the hasAll subset guard on authedUids; clientEquippableFrame and the equipProFrame callable; hostUid server-only via transferOwnership (~line 210). functions/src: 69 runTransaction call sites and 168 mentions of idempotency across 14 files, re-derived NUL-safely 2026-07-29 (grep -rao "runTransaction(" functions/src | wc -l -> 69; grep -raio "idempoten[a-z]*" functions/src | wc -l -> 168; grep -rail idempoten -> 14 files). -a is required because functions/src/balance.ts contains NUL bytes; offline_claim.ts returning {kind:'already'}. docs/audits/SECURITY_AUDIT.md, committed 2026-07-16 at 1.1.1+119.
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 provides an on-device text pass before the cloud extraction completes. A fast Gemini pass structures the receipt; confidence and arithmetic checks decide whether uncertain lines need another pass with a thinking budget and more focused image context. Both tiers currently use Gemini 2.5 Flash. The result is reconciled against the printed total, then shown in an editable review that keeps inclusive taxes, extra charges and discounts distinct. Inconclusive discount interpretations ask for a human check rather than silently changing the amount.
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
The pipeline connects extraction to an editable, reconciled receipt. Build 184 adds clearer discount choices, preserved price corrections and receipt layouts verified on both native platforms.
Where to look notes
functions/src/receipt_scan.ts: SCAN_MODEL_PRIMARY and SCAN_MODEL_ACCURATE both gemini-2.5-flash, SCAN_TRUST_THRESHOLD 0.85 and SCAN_FALLBACK_ITEM_CONFIDENCE 0.7; docs/releases/1.4.6_build_184_verification.md.
08/TECHNICAL DECISIONS
Why this way.
Entitlements are server truth
Chose RevenueCat webhooks into a server-held entitlement, re-read before any privileged action
Instead of Trusting the purchase SDK's cached entitlement on the device
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.
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.
Where to look notes
firestore.rules clientEquippableFrame comment, verbatim: "Pro frame ids are DENIED here on purpose: the ONLY route onto a Pro frame is the equipProFrame Cloud Function (admin SDK bypasses rules), which re-checks the live customers/{uid} entitlement first."; functions/src/index.ts revenueCatWebhook with REVENUECAT_WEBHOOK_AUTH from Secret Manager; resetExpiredProPerks, getProLapseGraceStatus, trialReminderNotifier, winBackChurnedPro, giftProFromSubscriber, moveProEntitlementLink; lib/features/pro/data/billing_service.dart.
Use native code where the platform requires it
Chose Flutter for shared screens, with native camera and widget integrations and CocoaPods-backed ML Kit
Instead of Treating a dependency-manager preference as a permanent product constraint
Widgets need platform-specific rendering and receipt scanning benefits from on-device recognition. The current build accepts the dependency work that comes with that: simulator compatibility is handled explicitly, and device binaries are checked again after archiving.
Trade-off Platform-specific build configuration, native integration tests and separate device-artifact verification.
Where to look notes
ios/Podfile; ios/Runner/QrScannerPlugin.swift; docs/releases/1.4.6_build_184_verification.md.
A country-neutral core with regional adapters
Chose Treating Malaysian behaviour as one adapter among many rather than the default
Instead of Shipping the local currency as the default and generalising later
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.
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.
Where to look notes
CLAUDE.md: "Any country-specific system (MYR, DuitNow, Malaysian examples) is a regional adapter behind the country-neutral core — never the default … Never hardcode RM/MYR as a fallback in new code"; lib/core/region/region_source.dart provenance ladder; lib/core/utils/currencies.dart (466 lines); the four-mirror format note in CLAUDE.md. docs/APP_OVERVIEW.md §1 is deliberately NOT cited here: line 12 argues the opposite ("Currency: Ringgit (RM) is the default and the whole app is designed around it") and is stale. It is still cited in challenges[0], where it is used only for "there is no FX conversion", which §1 does say.
Timezone names, never offsets
Chose Persisting the user's IANA zone name and resolving the local hour on the server at send time
Instead of Sending the device's UTC offset along with the preferred 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.
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.
Where to look notes
pubspec.yaml flutter_timezone justification, which names the bug verbatim; functions/src/just_you_zone.ts (DST_SEARCH_MARGIN_MINUTES, HOURLY_TICK_MS); functions/test-unit/just_you_dst.test.mjs, just_you_timezone.test.mjs, just_you_zone.test.mjs.
Cloud Functions are deployed by name
Chose Naming every function in the deploy command and keeping the order in a runbook
Instead of The one-line deploy that every Firebase tutorial teaches
The current source exports 119 handlers. Naming the functions in each deployment limits its scope and makes the intended production change reviewable. Build-freshness, signing and deployment checks sit in release scripts and runbooks.
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.
Where to look notes
AGENTS.md named-deployment rule; static source count 2026-09-05: 119 export const handlers using onCall/onRequest/onSchedule/onDocument*/onObject*; scripts/check_release_config.sh.
09/DESIGN EVOLUTION
What changed, and when
From first release to 1.4.6
Bupples first reached the App Store on 2 July 2026. As of 6 September, version 1.4.6 build 186 is live on Google Play and the iOS update is awaiting review. The earlier build 184 verification covers its iOS archive, signed Android bundle and native fixture checks on both platforms.
Evidence notes
Owner-supplied archive, 6 September 2026: Google Play production reports 1.4.6 build 186 at 100% rollout; App Store Connect reports iOS 1.4.6 WAITING_FOR_REVIEW and 1.4.5 READY_FOR_SALE. docs/releases/1.4.6_build_184_verification.md.
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.
Evidence notes
docs/audits/SECURITY_AUDIT.md, committed 2026-07-16 at 1.1.1+119 (commit 44ff9ffd); firestore.rules now carries resource.data.authedUids.hasAll(request.resource.data.authedUids) with the comment naming the swap attack.
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.
Evidence notes
docs/audits/perf-rework-2026-07/ (11 domain audits + COMPLETION_REPORT.md); docs/PERF.md.
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.
Evidence notes
docs/PERF.md — the five budgets, stated as blocking a release build; integration_test/perf_soak_test.dart with on-device FrameTiming.
Receipts, privacy and the paths between screens
The 1.4.6 work adds standalone expense invitations, participant-scoped private expenses and a step-by-step receipt review. It also addresses duplicate retries, stale membership records blocking balances, preserved edits and payment history that explains how a debt was resolved.
Evidence notes
Bupples source inspected 2026-09-05, pubspec.yaml 1.4.6+184; docs/releases/1.4.6_build_184_verification.md and 1.4.6_whats_new.md.
10/TESTING & RELIABILITY
How it is proven.
The 5 September 2026 build 184 verification record reports 6,048 Flutter tests, 803 backend unit checks and 456 Firestore rules checks passed, with clean Flutter analysis. Native fixture flows passed 91 checks on each platform. Those native checks use fixture repositories and scanner responses; they do not prove production payment or authentication behaviour. The figures below are from that release record, not a new test run performed for this portfolio.
- Flutter tests passed
- 6,048
- Backend unit checks passed
- 803
- Firestore rules checks passed
- 456
- iOS fixture checks passed
- 91
- Android fixture checks passed
- 91
- Flutter test files in source
- 642
How it was counted notes
Bupples source inspected 2026-09-05, pubspec.yaml 1.4.6+184; docs/releases/1.4.6_build_184_verification.md and 1.4.6_whats_new.md.
How it was counted notes
Bupples source inspected 2026-09-05, pubspec.yaml 1.4.6+184; docs/releases/1.4.6_build_184_verification.md and 1.4.6_whats_new.md.
How it was counted notes
Bupples source inspected 2026-09-05, pubspec.yaml 1.4.6+184; docs/releases/1.4.6_build_184_verification.md and 1.4.6_whats_new.md.
How it was counted notes
Bupples source inspected 2026-09-05, pubspec.yaml 1.4.6+184; docs/releases/1.4.6_build_184_verification.md and 1.4.6_whats_new.md.
How it was counted notes
Bupples source inspected 2026-09-05, pubspec.yaml 1.4.6+184; docs/releases/1.4.6_build_184_verification.md and 1.4.6_whats_new.md.
How it was counted notes
Static count of *_test.dart files under test/, 2026-09-05: 642. File count, not an executed test count.
Representative cases
- A payment on a rerouted plan row — The routed-settlement test covers the case that used to lose money: a payment recorded against a fewest-payments row where the payer and receiver share no direct debt must reduce the debts the payment actually covers, and must never create a reverse one.
- Receipts that do not add up — Reconciliation, tax and service allocation, and handwritten tip inference each have their own suite, exercising named bounded tolerances — including per-currency cash rounding — rather than a general fuzz factor.
- Reminders across a daylight-saving boundary — Three suites cover zone resolution and the DST boundary specifically, because the bug that motivated the design only appears in the weeks after a clock change.
- Private data and membership recovery — Rules and backend regression cases cover participant-scoped records and membership classification. The build 184 provider-chain regression reproduces a permission failure that previously blocked the first net-balance load.
11/RESULTS
What shipped.
- 1.4.6 · Live on Android
- Bupples is live on both stores. Version 1.4.6 build 186 has reached 100% production rollout on Google Play; iOS 1.4.6 is awaiting review, with 1.4.5 available. Status captured 6 September 2026.
- 46 profiles with a handle
- The 6 September 2026 snapshot holds 212 account records, including 46 profiles with a non-empty handle. Stored profiles may include internal, test, demo and anonymous activity; they are not verified people or active-user counts.
- 73 hangouts · 85 expense records
- The snapshot also holds 34 receipt records, 46 settlement-request records and 128 chat messages. These totals include owner and test activity and exclude deleted records. Captured 6 September 2026.
- 10,302 backend executions in 30 days
- Cloud Monitoring recorded 10,302 Cloud Function executions from 6 August to 5 September 2026. These include 19 scanReceipt calls, 26 askPip calls and 14 confirmSettlement calls. Scheduled jobs, retries and development activity are included, so executions are not unique users or completed actions.
- 48 weekly active users in GA4
- GA4 reports 48 active users for 30 August–5 September 2026, and 64 total users across 9 August–5 September. Internal/test traffic exclusion was not verified, so these are analytics identities rather than a count of paying customers or verified people.
- Release verification across both platforms
- The build 184 record reports 6,048 Flutter tests passed and 91 native fixture checks per platform. The iOS archive and Android bundle share a verified source fingerprint, and packaged versions and signatures are checked before delivery.
12/REFLECTION
In hindsight
What worked
- One split kernel, called from every money surface. A rounding rule belongs in the shared domain, so a correction can reach the expense editor, the ledger and settlement calculations together.
- 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.
- Measure completed flows and returning users with the analytics now wired into the app. Database totals and function calls answer operational questions; they do not establish retention.