Every budgeting app assumes a salary. This one is built for people with five income streams and one rent day, and answers the only question that matters: will I still be liquid in six weeks?

Solo build2025-2026Paused, never submitted

01The problem

A slasher in Hong Kong — the freelancer, the side-hustler, the person with a day job plus two gigs — does not have a salary. They have four or five income streams arriving on different dates in different amounts, against rent and subscriptions that arrive on exactly the same day every month. Every consumer budgeting app assumes the opposite: fixed income, variable spending. So the one question a slasher actually needs answered, given what is invoiced but unpaid, will I still be liquid in six weeks, is the one question no app answers.

The workaround is a personal spreadsheet, rebuilt from memory once a month, that goes stale within a week. And the mental accounting underneath is genuinely hard: money earned, money invoiced, money that has landed, and money already spoken for by a standing charge are four different things, and consumer apps collapse all four into one balance.

02What I built

A cross-platform finance dashboard: a React app running natively on iOS through a native wrapper, and from the same bundle on the web. It models a slasher’s actual mechanics — multiple income streams with irregular timing, recurring burns on monthly, quarterly and yearly schedules, invoices with deposit-and-balance splits, and accounts that can be locked or excluded from the liquid total. On top of that model it projects a forward cash curve, scores financial health, and files every transaction against a bilingual category and tag system. The whole surface is built to one written design system: high-density, numbers first, modelled on a trading terminal rather than a lifestyle app.

  • A forward projection that rolls recurring income and burns day by day, up to a 365-day horizon.
  • Financial health scoring with a per-metric radar breakdown covering liquidity, runway and related measures.
  • An invoice engine with deposit and balance splits, business entities, bank details and three document templates.
  • Three capture paths into one transaction store: a manual sheet, receipt photographs read by a vision model, and a phone shortcut that parses a bank notification and hands the amount straight to the entry sheet.
  • A local tax layer: deductible tagging on the April-to-March fiscal year used here.
  • A bilingual interface with a live in-app language switch covering the whole shipped surface — though the two key sets are not mirror images of each other, and the metrics table says by how much.
Schematic of the dashboard, drawn for density A drawn, abstracted representation of the dashboard: a left navigation rail with four sections, a row of three summary tiles, a forward projection line with the current point emphasised, and a five-row transaction list on a deliberately tight row pitch with three columns and a status mark on each row. The row spacing is the point of the drawing. Every figure, label and curve is placeholder; no real balance, invoice, account or client appears. SLASH OVERVIEW CASHFLOW INVOICES TAX SUMMARY PROJECTION, PLACEHOLDER ENTRY CATEGORY STATUS

Drawn, not captured. The tight row pitch is the argument, not a rendering accident: the design system mandates smaller list padding, smaller gaps and smaller inline icons than the platform convention, so more of the picture fits above the fold. The tiles, the projection curve and every row are placeholder; no balance, invoice, account or client shown here is real, and none is derived from real data.

03As a product

Who buys it
Hong Kong freelancers, contractors and multi-income individuals whose earnings are lumpy and who file their own returns. Secondarily, one-person limited companies invoicing a handful of clients that need receivables visibility without an accounting package.
Value
It answers the question a bank app cannot: not what is my balance, but what will my balance be, given what is invoiced, what recurs, and what is locked. Built around local specifics — the currency, the April-to-March year, the deductible categories, and Traditional Chinese as a first-class language rather than a translation layer.
Positioning
Consumer budgeting apps assume fixed income and treat forecasting as an afterthought. Accounting software forecasts properly but is priced and shaped for a business with a bookkeeper. This sits between: the forecasting rigour of the accounting tools at consumer weight, on a phone, in Chinese first. The differentiator is information density.
Status
Paused, and not commercialised. A three-tier plan system is wired end to end, including limits, feature gates, purchase flow and server-side plan integrity, but no store submission was ever made and no paid user has ever existed.

04How it works

Stack
React with TypeScript, Vite, Tailwind, a lightweight state store, a charting library and an icon set. A native wrapper ships the same bundle as an iOS app with haptics, camera and back-button handling. Supabase provides Postgres, authentication and edge functions; a subscriptions service handles purchases on both native and web; a vision model reads receipts; Vitest and CI run the tests.
Shape
Three doors, one write path, derived state computed on read and never stored. Rendering goes through a widget registry, so the dashboard is a reorderable list of type identifiers rather than a hardcoded layout, and feature gates decide at render time whether a widget shows data or an upgrade prompt.
Three capture doors, one write path, five levels A vertical flow in five levels, every band drawn at uniform width because no path here was ever counted. Three capture doors — a typed entry sheet, a photographed receipt read by a vision model, and a phone shortcut that parses a bank notification into a deep link — converge on one client-side store, so there is exactly one write path. Below it the flow divides: rows continue through a repository layer into Postgres, while the derived state, the forward projection and the health score, is computed on read and ends there, never written. The plan column travels the other way and is never written by the client at all. CAPTURE Sheet, receipt, deep link TYPED PHOTO LINK STORE One write path, client side ONE TRANSACTION, PER ENTRY DERIVE Computed on read, not stored SAVED DERIVED REPOSITORY Only code that talks to SQL ROWS OUT, PLAN BACK IN POSTGRES Plan column, server-set only

Width uniform throughout — nothing in this ledger was ever instrumented per path, so every band is drawn at equal width and no quantity is claimed.

The client is never allowed to know its own entitlements. A plan can only be written by a webhook arriving at a server function, and database triggers force new rows to the free tier and reject any escalation sent from a device — which is the only reason a paid account cannot quietly revert on the next login.

The decision I spent longest on

Deciding that the client is not allowed to know its own entitlements. The first pass had the app write its own plan field after a successful purchase, which meant any user could promote themselves with one line in a console — and, worse, paid users silently reverted to free on re-login, because nothing outside the device had ever recorded the purchase. Making the plan column server-authoritative cost a full architectural detour on a solo project with zero paying users, which is exactly why it was tempting to skip. A payment pipeline that quietly loses purchases is not a bug you can retrofit after the first customer.

05Retrieval architecture

A single person’s ledger is the smallest interesting corpus there is: hundreds of rows, all structured, all already the user’s own. Almost everything usually built to make retrieval work — an index, a query parser, a ranker — exists to survive a scale this system does not have and, paused where it is, never will.

The one retrieval-shaped problem here arrived from the other direction. A model was allowed to read a receipt, and not to name a category, because free text landing inside a closed set is a matching problem rather than a generative one, and the safest answer to it is often nothing at all.

ModelAt index time only. A vision model reads a photographed receipt as it enters; nothing calls a model when the ledger is read, filtered, projected or scored.

Corpus
One user’s own financial record, held in Postgres behind a single repository layer and loaded whole into a client-side store: transactions, accounts, recurring income and burn schedules, invoices with deposit and balance splits, and a bilingual category and tag system over the top. Hundreds of rows at the scale it was built for, and never more than one person’s.
Ingestion
Three doors, one write path, nothing unattended. A transaction arrives typed into an entry sheet, photographed as a receipt and read by a vision model into structured fields, or handed over by a phone shortcut that intercepts a bank notification and opens a deep link with the amount already parsed. All three converge on the same store, one transaction per entry, so there is a single place where anything enters and a single place to audit when it goes wrong.
Index
None. Nothing was built here, because the ledger is loaded whole. At hundreds of rows the entire set is already in memory before the first screen renders, so an index would be a second structure to keep in step with the first, and the only indexes in the system are the ones Postgres maintains on the keys it was given. The forward projection walks recurring schedules forward day by day rather than looking anything up.
Query
None. Nothing arrives as language. A date range, a category, an account or a tag is chosen from a control, and each of those is already a predicate — there is no search box, no phrase to expand and no intent to guess at. The one free-text string in the whole system comes out of the vision model rather than from the user, and it is treated as a value to be matched, never as a question to be understood.
Selection
A predicate over the ledger already in memory: filter by date, account, category or tag, group by day, and compute the derived views on read rather than storing them — the forward curve walks recurring income and burns across the horizon the plan allows, and the health score is the same base data scored again. Nothing is ranked at any point, so there is nothing to rerank; ordering is chronological because that is the only order a ledger has, and a widget registry decides what is shown rather than any scoring of what came back.
Grounding
Two gates, both of them refusals. A vision model returns a receipt’s category as free text, which cannot be trusted to land inside the app’s closed set, so a small keyword lexicon bridges the two — an exact identifier match first, then a substring test in both directions against a short per-category keyword list — and if nothing matches it returns nothing, so the transaction arrives uncategorised rather than confidently mis-filed. The second gate is the entitlement: the plan column is server-authoritative, written only by a purchase webhook reaching a server function, with database triggers forcing new rows to the free tier and rejecting any escalation sent from a device.

Present — this layer exists and runs.Absent by decision — the layer is not there, and the sentence beside it is the reason. Every one of the six is answered on every system in this chapter, so the rows can be read across pages.

06Numbers

MeasureFigureBasis
React components shipped52Verified
TypeScript and TSX written19,564 linesVerified
Commits, 2025-12 to 2026-04180Verified
Dashboard widget types19Verified
Interface strings, Traditional Chinese and English589 / 588Verified
Keys present in only one language, each direction9 and 9Verified
Automated tests across three suites76Verified
Main JavaScript bundle1.9 MBVerified
Rows per screen against a stock platform list1.5–2xProjected

Verified — counted in the repository as it stands, from file listings, line counts, the git log and the built bundle. The two language key sets are close but not identical — nine keys exist only in Chinese and nine only in English, so the bilingual surface is complete rather than mirrored. Projected — derived from the design system’s own numbers, not measured on a device: smaller list padding, smaller row gaps and smaller inline icons than the platform convention, compounded. No pixel measurement was ever taken.

07Timeline

  1. 2025-12First commit. Initial structure, and the first version of the finance model.
  2. 2026-03The core finance model, the dashboard widgets and the charts land across the 2.0 and 2.1 era.
  3. 2026-04Foundation refactor: a real build pipeline replaces a CDN stylesheet, a legacy backend is removed, and a 1,326-line root component is broken down to 465 lines behind stores. A second review round rebuilds monetisation as server-authoritative, and the onboarding wizard, invoice output, Chinese interface, variable-income model and tax layer all arrive.
  4. 2026-04Receipt reading is completed, tests and CI are added, and the device install pipeline is confirmed. Final commit on the 18th, with the codebase clean and compiling. A self-run security and privacy audit scores the release four out of ten and recommends holding it.
  5. 2026-07Repository archived into cloud storage. No code work since.

08Looking back

What broke

The monetisation pipeline was silently broken for weeks: purchases were recorded only on the device, so a paid user reverted to free the moment they logged in again, and nothing surfaced it. The income model was wrong at first too — it assumed fixed recurring income, which is precisely the assumption that makes every other budgeting app useless here, and reworking it touched the type system, the store and the projection engine at once. The test suite immediately earned its cost by exposing that quarterly and yearly burns were silent no-ops in the projection: only monthly schedules were ever processed, so any forecast containing an annual charge had been quietly wrong. Then the audit blocked release outright. A third-party vision key was shipping inside the client bundle where anyone could extract it, and receipt images containing personal data were crossing a jurisdictional border with no consent step — a real problem for a finance app under local privacy law.

What it changed

The design system outlived the app. What began as decisions about one product — numbers first, labels second, decoration last; every colour carries semantic meaning or is not used — was written up as a standing, enforceable specification with an anti-pattern table and a post-edit review checklist, so that any later contributor, human or otherwise, is held to it. That practice of extracting a design language into an auditable document, rather than leaving it in the code as taste, carried into everything after. So did the server-authoritative entitlement pattern.

Where it stands

Paused, and honestly so. It was never submitted: no developer account was obtained, which blocked live purchase testing, push notifications and code signing at the platform level. The web deployment no longer resolves. Two audit findings were left standing as release blockers. It is a complete, working, unshipped product.

Back to Top