Solo product · Personal finance
Qirsh
Modelling the machinery a bank actually runs, not the spending you already did
Context
Budgeting apps are good at the past. They categorise what already left your account and draw a chart of it. That is a solved problem, and it is not the one that keeps people awake - what does is the next statement: what is carried, what earns interest, what the instalment plan will ask for this month, and how much of a loan payment is principal rather than cost.
Qirsh models that machinery instead of summarising receipts. It treats a credit-card statement period as a real entity with a lifecycle, models the grace period the way a bank actually grants it, and - the part that started the whole thing - understands flat-rate lending, the convention used across the region it was built in, which mainstream finance apps do not model at all.
It is also honest about its edges. There is no bank login, no automatic import, no investments and no household sharing, and the landing page lists those absences above the sign-up button rather than below it.
My role
Solo, end to end: the domain model, the Postgres schema and its migrations, the GraphQL API, every screen, the authentication and two-factor flow, the deployment, the CI pipeline, the production migration process and the backup job.
A finance app is a bad place to learn that a design decision was wrong, so most of the work went into deciding things once and writing down why. Several of the choices below are recorded in the repository as ratified design notes, including one that deliberately departs from the plan it was implementing - with the arithmetic reason for the departure written out.
Ownership, layer by layer
Where this work sat in the stack, and how much of each layer was mine.
Requirements
BuiltDefined the product and its differentiator, and enforced a rule that every public claim be checked against shipped code before it was written.
Interface
BuiltThe full Next.js application - dashboard, forecasting, transactions, budgets and goals, onboarding, command palette and installable PWA.
Integration
BuiltThe GraphQL API across around twenty schema domains, two-factor authentication, web push, and generated-type drift checking in CI.
Services
BuiltMoney maths in exact decimals, the single loan amortisation engine, statement close and the payday cash-runway projection.
Data & cloud
BuiltPostgres schema and migrations, encryption at rest with HMAC lookup, soft deletion with partial unique indexes, and append-only loan term history.
Delivery
BuiltVercel deployment with scheduled jobs, GitHub Actions CI, a guarded production migration workflow, a self-verifying backup job, security headers and error monitoring.
- Built Designed and implemented this myself.
What I built
Data & cloud
A schema that encodes policy
Every monetary column is an exact decimal rather than a float, amounts are always stored positive with direction carried by the transaction type, and soft deletion runs throughout with uniqueness re-expressed as partial indexes over live rows only.
Loan terms as an append-only history
Amending a loan appends a new immutable version rather than mutating the old one, so the original contract survives a reschedule - which is what makes “interest saved against the deal you signed” answerable at all.
Email that is never stored in the clear
Addresses are encrypted at rest and looked up through a deterministic peppered HMAC, so there is no plaintext email directory in the database and login still works in one indexed query.
Services
One amortisation engine
The projection loop, the payment-number-to-date map and the interest rule were previously duplicated across five call sites that could silently drift apart; they are now a single module that every surface reads from.
Statement close
Closing a period computes carried balance, activity and interest, marks superseded periods as carried so the same debt cannot be shown as owed twice, and runs a self-reconciliation cross-check that logs loudly if the two ways of deriving the figure disagree.
Cash runway to payday
The forecast window ends at the next salary date rather than at month-end, and projects each card the way its owner actually pays it - in full, minimum, a fixed percentage or a fixed amount.
Interface
The dashboard
Overview, insights and forecast, accounts and statement periods, transactions, transfers and recurring rules, budgets and goals - with a command palette and an onboarding wizard.
Documents
Server-rendered PDF statements and loan settlement letters, generated from the same figures the screen shows.
Installable, without an app store
A progressive web app with a service worker, install prompt and web push notifications with de-duplication - which is the actual distribution channel, since there is no store listing.
Integration
A typed GraphQL API
Around twenty schema domains with matching resolvers, and a CI step that regenerates the client types and fails the build if the committed output has drifted from the schema.
Two-factor authentication
TOTP with recovery codes, and login attempts recorded against a hash of the address rather than the address itself.
Delivery
Migrations that do not run in the build
Production schema changes run in their own queued workflow triggered only by migration changes, behind a preflight ordering check - not inside the deployment build, where a failure would be entangled with a release.
Backups that verify themselves
A weekly job dumps, verifies the dump can be listed and is not suspiciously small, encrypts it, uploads it offsite, reads it back to compare checksums, and alarms if the newest copy has gone stale. It refuses to upload at all if encryption is not configured.
Operational plumbing
Vercel with scheduled jobs, Neon Postgres, a full security-header set including a restrictive content security policy, error monitoring, and analytics reverse-proxied through the app’s own origin.
The decisions that were actually hard
ProblemMulti-currency support sounds like a feature. In an app whose whole purpose is aggregates - net worth, spendable, runway - it is a correctness hazard: two currencies in one ledger silently produce a total that means nothing, and no error is raised because addition still works.
ResolutionOne currency per user, fixed at account creation and enforced by construction rather than by a check. Nothing converts anywhere, so every aggregate is single-currency by definition. Changing the setting relabels every existing account in the same transaction. Per-currency decimal places are a display rule only - storage keeps full precision, so switching from a three-decimal currency to a two-decimal one hides a digit rather than destroying it.
ProblemA credit-card statement’s carried balance is easy to get wrong by chaining each statement from the previous one. Do that and any debt that existed before tracking began, or that never appeared on a statement, is simply invisible - and imported history, which carries zero, guarantees the error.
ResolutionThe carried figure is derived from the ledger itself - the balance at the period’s start, reconstructed from live transactions - rather than folded forward from the previous statement. This is recorded as a locked decision, because it is the one that finally captures opening balances and off-statement debt.
ProblemCharging interest on the whole carried balance is what a naive model does, and it is wrong in the common case: paying last month’s statement partway through this cycle earns the grace period on a real card, but a naive model would still charge a full month of interest.
ResolutionPayments made inside the window are subtracted before interest is computed, so the grace period behaves the way the bank behaves. Once a period closes, every earlier live period is marked carried into it, which also removes that debt from every overdue and runway surface - the same obligation is never counted twice.
ProblemInterest is easy to apply as a quiet adjustment to a balance. Do that and the ledger no longer explains the balance: the sum of the transactions and the account balance drift apart, and nobody can see why.
ResolutionWherever interest accrues, a real dated finance-charge transaction is written inside the same database transaction that moves the balance. The invariant - that the transactions sum to the balance - holds on every account. The row is dated to the end of the closed period rather than to the moment of closing, because a late close would otherwise land the charge inside the next period.
ProblemA loan quoted at “6% flat” is not a 6% loan. Flat-rate interest is charged on the original principal for the whole term rather than on the declining balance, which is the regional convention - and every mainstream budgeting app assumes reducing-balance credit and has no concept of it.
ResolutionBoth conventions are modelled, and a solver recovers the true effective annual rate by finding the rate whose declining-balance payment matches the flat loan’s actual monthly payment. A 6% flat loan surfaces at roughly 11.3% effective. Bisection is safe here because the payment is strictly increasing in the rate.
ProblemReversing a transfer between two accounts is symmetrical only if both sides hold money. Against a credit card or a loan it is not: paying a card consumes cash permanently, so reversing the payment credits the funding account with money that no longer exists. This was not hypothetical - reversing a run of statement payments inflated net worth by the entire sum repaid.
ResolutionCash accounts and debt ledgers are a first-class distinction in the code, and peer-leg reversal is permitted only between two cash accounts. A related predicate keeps archived accounts out of forecasts while deliberately leaving them in historical aggregates, because archiving an account must not rewrite last year’s spending.
Technology
Built with
- TypeScript
- Next.js (App Router)
- React
- GraphQL
- Prisma
- PostgreSQL
- Vercel
- GitHub Actions
- Vitest
- Web Push
- TOTP two-factor
- Progressive web app
Where it landed
Qirsh is live in production with a real database behind it, a CI pipeline that blocks a merge on lint, generated-type drift and a full database-backed test run, and production migrations that run in their own guarded workflow.
What I would defend at a whiteboard is not the feature list - it is that the hard parts are written down. The carried-balance derivation, the grace-period base, the finance-charge invariant and the cash-versus-debt distinction each exist as a recorded decision with the failure it prevents stated next to it, including one place where the implementation deliberately departs from the plan it was following because the plan was arithmetically incompatible with a decision already locked.
The repository is equally direct about what is not finished: the offsite backup job has never run against real credentials, so it is built but unproven. That is in its own handover notes, and it belongs here too.