Bupples

Shared expenses, receipt review and settlement plans, backed by a multi-currency ledger.

App Store (opens in a new tab)Google Play (opens in a new tab)

Role
Sole developer: product design, Flutter client, Firebase backend, security rules, release
Dates
Feb 2026 – present
Status
App Store, Google Play · 1.4.6
Stack
Flutter, Firebase, Vertex AI · 6,048 tests passing
Bupples profile screen showing the member’s handle, mascot and hangout stats Bupples accent colour editor with a colour wheel and preset swatches Bupples settings screen with appearance, privacy and notification preferences
  • Bupples activity feed with expense and payment filters and dated payment states.
  • Bupples profile showing a decorated avatar, Pip Pro options and personal activity statistics.
  • Bupples receipt review for a dinner with friends: vegetable soup and pasta in euros, tax and service at zero, and a check that the lines match the receipt total of EUR 23.00, above Back and Create split.
  • Bupples accent editor with a colour wheel, shade slider and live interface preview.
  • Bupples settings with appearance, profile customisation, Pip and splitting preferences.

In short

  • Live on iOS and Android

    I designed and built the app, receipt scanning, settle-up flows and the Firebase backend. Version 1.4.6 is on the App Store and Google Play.

  • Money that adds up

    Amounts are integer cents run through one shared split kernel, so a split always sums to the bill and a double-tapped payment applies once.

  • Receipts checked before splitting

    On-device text recognition runs first, Gemini structures the receipt, and every line is checked against the printed total before anyone splits it.

Settlements are written on the server

  1. On the device

    • Flutter client (iOS and Android)
    • Native surfaces
  2. On my infrastructure

    • Firebase (managed)
    • Cloud Functions (Node 22, TypeScript)
    • Receipt extraction
    • Firebase Hosting
  3. Outside the system

    • Subscriptions
What runs on the phone, what runs on the server, and where money is allowed to be written. Parts and connections

Engineering challenges

  1. Cent-exact money with one place that divides it

    Balances agree on every screen and device, and a rounding remainder always lands somewhere predictable. Multi-currency groups need no exchange-rate feed, because nothing is converted.

  2. A fewest-payments plan that could lose money

    A payment against a rerouted row now reduces the real debts it covers. Pair balances and settle-up always read the direct ledger; the simplified plan only suggests who pays whom.

  3. Receipt claims that agree to the sen in Dart and TypeScript

    The share a person sees while tapping items is the share the ledger stores. A receipt that disagrees with its own lines shows the difference for someone to resolve.

  4. A trust boundary in the rules and Cloud Functions

    The highest finding from my own audit, a member rewriting the authorised list to evict the host, is closed in the rules. The findings still open are listed in the notes.

  5. A second, slower receipt read only when the first is unsure

    Every scan ends in an editable receipt checked against its printed total. The latest release adds clearer discount choices and keeps price corrections between steps.

Technical decisions

DecisionWhat I choseInstead of
Subscriptions are checked on the serverRevenueCat webhooks into a server-held entitlement, read again before any paid actionTrusting the purchase SDK’s cached entitlement on the device
Native code where the platform requires itFlutter for shared screens; native camera, widgets and ML KitKeeping the iOS build on Swift Package Manager alone
A country-neutral core with regional adaptersMalaysian behaviour as one regional adapter among manyShipping the local currency as the default and generalising later
Reminders keep the time-zone nameThe IANA zone name, resolved to the local hour at send timeSending the device’s UTC offset with the preferred time
Cloud Functions are deployed by nameEvery function named in the deploy command, in a runbook orderThe one-line deploy most Firebase tutorials teach

Live on both stores at 1.4.6

Flutter tests passed
6,048
backend unit checks passed
803
Firestore rules checks passed
456
weekly active users in GA4
48

Tests from the release checks on 5 September 2026. GA4 users for 30 August to 5 September 2026, with my own test activity included.

Working with AI tools

Define: Money invariants cannot tolerate fractional-cent drift or client-side tampering. Decide: Represent every amount in integer cents with strict server-side validation. Investigate: Agents repeatedly generated client-side floating-point settlements that drifted over multi-currency chains. Verify: Enforced atomic transaction rules in Cloud Functions, backed by 6,048 passing ledger and settlement tests.

Full engineering notes

13 sections, about 22 minutes to read

Summary

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. Most of the work is making the money add up: a split always sums to the bill, a retried request never records a payment twice, and a private expense stays with the people in it. 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.

  • 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.

The problem

Bills are shared, edited and paid by several people on different phones, often days apart.

  • 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, people stop trusting the app. That rules out rounding 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.

  • Settling takes more than a number

    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.

My role

What I owned

  • 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.

Constraints

  • 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.

  • 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.

  • 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.

  • 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. So the expensive read runs only on the receipts that need it.

Product flow

  1. Capture

    Photograph a bill, adding overlapping images when a long receipt needs more than one scan.

  2. Read

    On-device ML Kit reads the text; a Gemini pass structures items, quantities, discounts, tax and totals.

  3. Check

    Reconcile the items against the printed total. Uncertain lines and unexplained differences stay visible for review.

  4. Review

    Correct items and choose whether discounts are already included or still need applying. Edits survive moving between steps.

  5. Split

    Add people and allocate items. The Dart preview and TypeScript writer use integer arithmetic and shared conservation rules.

  6. Settle

    Record and confirm payments, with history explaining who paid, what changed and which debts the payment covers.

One receipt, from the shutter to a settled balance.

Architecture in detail

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.

PartWhat it holds
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

Connections between the parts

  1. Flutter client (iOS and Android) to Native surfacesPlatform channels; widget data through an App Group
  2. Flutter client (iOS and Android) to Receipt extractionOn-device OCR runs before the network call
  3. Flutter client (iOS and Android) to Firebase (managed)Direct reads and writes, every one checked by rules
  4. Firebase (managed) to Flutter client (iOS and Android)Realtime listeners and membership projections
  5. Flutter client (iOS and Android) to Cloud Functions (Node 22, TypeScript)Callables for anything money- or membership-critical
  6. Cloud Functions (Node 22, TypeScript) to Firebase (managed)Admin SDK writes: joins, settlements, finalised receipts, ownership
  7. Cloud Functions (Node 22, TypeScript) to Receipt extractionVertex AI with Application Default Credentials, no API key
  8. Flutter client (iOS and Android) to SubscriptionsPurchase and restore through the store
  9. Subscriptions to Cloud Functions (Node 22, TypeScript)Entitlement webhook, authenticated with a Secret Manager secret
  10. Cloud Functions (Node 22, TypeScript) to SubscriptionsLive entitlement re-read before any privileged grant
  11. Firebase Hosting to Cloud Functions (Node 22, TypeScript)The browser claim page calls the same callables
  12. Firebase Hosting to Firebase (managed)Anonymous sign-in, then the same rules apply

How each challenge was solved

Cent-exact money with 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.

What I built

Money is integer minor units end to end. Parsing does string-based integer arithmetic and never touches a float, 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.

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
Trade-off

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.

Validation

The invariants are asserted in tests: 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.

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.

What I built

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.

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
Trade-off

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.

Validation

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.

Receipt claims that agree to the sen in Dart and TypeScript

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.

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. 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.

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
Trade-off

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.

Validation

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.

A trust boundary in the rules and Cloud Functions

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.

What I built

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.

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
Trade-off

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; the rules stay strict.

Validation

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.

A second, slower receipt read only when the first is unsure

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.

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.

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
Trade-off

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 on the same model.

Validation

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.

Why I made each decision

Subscriptions are checked on the server

Because

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.

Native code where the platform requires it

Because

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-artefact verification.

A country-neutral core with regional adapters

Because

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.

Reminders keep the time-zone name

Because

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.

Cloud Functions are deployed by name

Because

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.

Design evolution

  • From first release to 1.4.6

    Bupples first reached the App Store on 2 July 2026. Version 1.4.6 is live on both stores: build 186 reached 100% rollout on Google Play by 6 September, and the iOS release followed on 7 September 2026. The earlier build 184 verification covers its iOS archive, signed Android bundle and native fixture checks on both platforms.

  • Audited my own shipped code, then fixed what it found

    At version 1.1.1 I reviewed the live code for attacks across eight areas, scored it five out of ten and listed every finding. The highest was a membership-privilege escape through the self-leave branch; the current rules carry the subset guard that closes it, with the attack written above the guard so the reason survives the next edit.

  • A performance rework driven by measurement

    Eleven read-only per-domain audits produced three named root causes: 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.

  • Performance budgets that fail the build

    The performance work ended in five written budgets: 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.

  • 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.

Testing

The release checks on 5 September 2026 passed 6,048 Flutter tests, 803 backend unit checks and 456 Firestore rules checks, with clean Flutter analysis. Native flows passed 91 checks on each platform against fixture data and recorded scanner responses.

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

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. A provider-chain regression test reproduces a permission failure that once blocked the first net-balance load.

Results in detail

  • Live on both stores at 1.4.6

    Bupples is live on both stores at version 1.4.6: build 186 reached 100% production rollout on Google Play, and iOS 1.4.6 was released on 7 September 2026.

  • 46 profiles with a handle

    The 6 September 2026 snapshot holds 212 account records, 46 of them with a handle.

  • 73 hangouts · 85 expense records

    The same snapshot holds 34 receipt records, 46 settlement requests and 128 chat messages.

  • 10,302 backend executions in 30 days

    Cloud Monitoring recorded 10,302 Cloud Function executions from 6 August to 5 September 2026, including 19 receipt scans, 26 questions to Pip and 14 settlement confirmations.

  • 48 weekly active users in GA4

    GA4 reports 48 active users for 30 August to 5 September 2026, and 64 users from 9 August to 5 September.

  • Release checks on both platforms

    The release checks report 6,048 Flutter tests passed and 91 native fixture checks per platform. The iOS archive and Android bundle share a verified source fingerprint, and versions and signatures are checked before delivery.

Reflection

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. Any view that reroutes money is a second ledger and needs the same checks as the first.
  • 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.
  • 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.
  • 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.

Stack and release record

  • Platforms

    iOS, Android

  • Built with

    Flutter / Dart, Riverpod, Firestore + Security Rules, Cloud Functions (Node 22, TypeScript), Firebase Auth, Storage, Messaging, Remote Config, Crashlytics, Analytics, Vertex AI (Gemini 2.5 Flash), Google ML Kit text recognition (on device), RevenueCat, Swift / SwiftUI / WidgetKit, Kotlin / Android RemoteViews, Firebase Hosting, GitHub Actions

  • Testing and delivery

    Flutter unit and widget tests, Cloud Functions unit checks and Firestore rules tests on the emulator run in GitHub Actions; the build 184 release record shows 6,048 Flutter tests, 803 backend checks and 456 rules checks passing, plus 91 native fixture checks per platform. Releases go through TestFlight and the Play Console to both stores, where version 1.4.6 is live.

  • Status in full

    Live on the App Store and Google Play

Hiring interns for January 2027?

I’m available full-time from 25 January to 23 April 2027.