← All work
Case 02 · Product of the practice · Mar 2025 — present

A product built to prove the practice on ourselves

The Fudger is a cash-flow calendar: every paycheck and every bill on the day it actually lands, and a running balance on every day in between — including the ones that have not happened yet. It is a real product heading into private beta, and it is also the only system SoulTech both recommends patterns for and has to operate at 2am. Three repositories, five client targets, one Go API, designed and shipped solo.

Why it exists

Every pattern the practice puts in front of a client — OpenAPI-first contracts, event-shaped jobs, release automation, test gates that cannot be waved through — gets run end to end here first. A recommendation you have never had to live with is an opinion, not a standard.

What it is

A marketing surface with an interactive demo, a React SPA that also ships as native macOS, Windows, Linux and iOS applications, and a Go REST API on Postgres and Redis. One OpenAPI document generates both sides of the wire.

Where it is now

The marketing site is live at fudger.io. The application is in invite-only private beta, deployed continuously to its development environment on every merge. Production infrastructure is specified and gated behind a written launch runbook that has not been executed yet.

The architecture

Three tiers, one generated contract

The rule the whole system turns on is not an infrastructure rule — it is a domain one. Fudger ignores time of day entirely: the calendar date a user picks is the date stored and the date returned, byte for byte, with no timezone conversion anywhere. Almost every structural decision below exists to make that rule hold under pressure instead of being restated in a code review.

Client tier
fudger-mktg
Static marketing site. The landing page is a playable demo — client-side math only, zero network calls, nothing to store.
fudger web
React 19 SPA behind nginx, which proxies /api same-origin so the browser never makes a cross-origin call in production.
native clients
macOS, Windows, Linux and iOS via Tauri, from the same bundle. The Rust shell is 58 lines.
generated client
TypeScript client generated from the same OpenAPI document that generates the server. 100 files nobody hand-edits.
↓ /v1 · bearer JWT↑ JSON · calendar dates as bare text
Service tier
fudger-svc
Go · Chi · Ent. OpenAPI-generated handlers over a single 105-method datastore interface. Recurring entries are projected per request, never materialised as rows.
↕ entries · overrides · anchors · sessions
Data tier
Postgres
Fly Managed Postgres. The five user-facing date columns are stored as text, deliberately, so no timezone can attach itself to one.
Redis
Sessions, plus the cross-instance store behind rate limits, entitlements and cooldowns. Degrades to process-local — never to “allow”.
Not shown: occurrences of a recurring series do not exist as rows. They are projected in memory on every request with a deterministic identity derived from the series and the date, so the same occurrence carries the same id on every response without ever being written down. Overrides — a rent rise for one month, a skipped payment — are the only rows a series ever spawns.

The contract between tiers is the OpenAPI document: 49 paths and 68 operations, from which the Go server interfaces and the TypeScript client are both generated. A field cannot drift between the two, because neither side is written by hand.

The work

Ten domains, seventeen months, one pair of hands

Design, API, front end, native clients, infrastructure, release engineering and the docs — solo, alongside client work.

The domain
rule

Made the timezone bug unrepresentable instead of fixed

A budgeting app has exactly one product unit: the calendar day. If a user in Los Angeles marks rent paid on 28 February, it is 28 February — not “16:39 Pacific”, and never “1 March UTC”. That rule kept getting eroded by well-meaning changes, because a database date type still comes back as a timestamp with a timezone attached, which leaves the same mistake available to the next person who touches it.

So the five user-facing date columns were moved to canonical YYYY-MM-DD text. Plain text has no timezone to misuse, and lexicographic ordering of that form is chronological, so range filters and sorting stay correct for free. The bug stopped being something to remember and became something the schema cannot express.

The migration that proved it was worth doing measured the damage first: on the live snapshot, 3 of 166 override rows resolved to a different calendar day depending on which reading you took — one of them across a month boundary, a settled rent payment that belonged in February and read as March.

3 of 166
Rows that resolved to a different day
5
Date columns moved to canonical text
3
Timezones exercised on every CI run
One spec,
two languages

Made the wire contract a build artifact instead of a convention

One OpenAPI 3.0 document is the source of truth for both sides. It generates the Go server interfaces and the TypeScript client, so a renamed field breaks a build, not a customer, and the two tiers cannot quietly disagree about a shape. The client repository re-exports a curated surface, so application code never reaches into generated files directly.

The generator is not treated as infallible. Where its behaviour was wrong for the domain it is wrapped, not patched: a query parameter that arrives present-but-empty was being coerced to a silent false by the generated parser, so the routes are mounted through a wrapper that strips those before they reach it — wrapping both the ordered and unordered route tables, because an unwrapped copy would be a second, silently different mounting.

Recurrence

Projected recurring money instead of storing it

A recurring entry is one row that is simultaneously the first occurrence and the template for every other one. Occurrences are computed per request, with an identity derived deterministically from the series and the date, so the same occurrence is addressable across requests without a table of millions of rows that all say the same thing.

What a series does spawn is overrides: rent went up in August only, or this one payment was skipped. Editing “this and all future” truncates the series and starts a new one, recording its lineage so the split can be reasoned about later. Payment state — paid, when, how much actually left the account — is deliberately never copied from the template onto a projection, because payment belongs to a specific occurrence and not to the pattern.

The recurrence vocabulary is the part users never see and always feel: “the 15th and the last business day”, rolling off a weekend the way payroll does, is a preset, not something to hand-assemble, and a bill due on the 31st lands on a real date in the months that do not have one.

Balances

Built a forecast that re-anchors instead of drifting

The signature move — the one the product is named for — is telling the app what the bank actually says. That is not a transaction and not a correction factor: it declares that on this date the balance is this number, the running total restarts from there, and everything before it stops mattering to every day after it.

Each day is computed independently from the most recent anchor instead of accumulated forward, so one bad day cannot poison the rest of the month, and the whole calendar can be read in two modes off the same ledger: planned, and confirmed-only — settled money, re-dated to when it really landed, at the amount that really left.

Getting that right meant treating precision as a correctness concern, not a display one. Anchors written through an older wire model had been narrowed to 32-bit floats, so $391.60 was stored as 391.6000061035156 — and because an anchor is an input, no amount of care on the output side can repair it. A dedicated repair pass identified them by exact float32 representation rather than by magnitude, and the endpoint now refuses a sub-cent balance outright.

Proving
the tests

Made test-driven development an artifact you can check

Nobody can audit whether a test was written before the code. What can be audited is the evidence it leaves behind, so CI does exactly that: every test a branch adds must fail against the branch it came from. A test that passes against unchanged production code is not a regression test for anything — it either transcribes the implementation it claims to check, or it describes behaviour that already worked.

Both failure modes had already shipped here before the check existed, which is why it exists. A test that fails to compile against the base counts as a pass, because referencing code the branch introduces is precisely what a test for new code looks like. The escape hatch is a deliberately conspicuous marker in the commit message, since a dependency bump has no failing test to show and pretending otherwise only teaches people to write throwaway ones.

The counterpart rule is that a deliberately failing test is welcome in history. That commit is the specification, and it is worth more than a test written afterwards to fit the fix — provided it is named as red in the message, and provided it is green before the pull request opens.

3,364
Tests across API, SPA and end-to-end
85,303
Lines of test code
82,451
Lines of production code
Design as
a contract

Turned the colour system into something tests can enforce

The interface ships eight colour schemes across five layouts and three entry treatments, in light and dark. That is a large enough matrix that “looks fine” stops being a review technique, so legibility was written down as a contract instead: text must clear 4.5:1 against the surface it is actually drawn on.

The word actually is carrying the weight. A tinted chip is a translucent wash over the cell, and measuring the text against the bare cell overstated contrast by up to 0.9 — six role and mode combinations passed on paper and failed where the text really was. The rule now measures against the composited surface, and a test walks every scheme, mode, treatment and direction, resolving the colours the component would really render. A further rule requires the income and expense colours to separate by brightness as well as hue, so the two directions survive greyscale and the common forms of colour blindness.

That test is the difference between a design system and a mood board: it makes the contract enforceable instead of aspirational, and it fails the build, not a user.

Timezone
matrix

Ran the date-sensitive suites in three timezones every build

The domain rule is only as good as the evidence that it holds, and a test suite that runs exclusively in the timezone of the machine that wrote it proves very little about a rule whose entire purpose is timezone independence. The date-sensitive suites are re-run under a zone west of UTC with daylight saving, and under the deepest zone without it, alongside the default run.

The harness sets the zone at process spawn instead of through the test runner, because that is what survives date-library caching and models how a browser sees a fixed session zone. It also fails if the matrix silently loses a file to a rename — a guard that exists because a reviewer asked the obvious question about what happens when someone moves one.

One machine
→ many

Took the service from one instance to several, then proved it

The original design had a hard ceiling: state that lived in process memory, and a database on a volume that attaches to exactly one machine at a time. Getting past it meant a Postgres cutover and moving every shared control — rate limits, entitlement caching, per-user cooldowns — onto Redis behind a deliberately narrow interface, small enough that a fake cannot cheat and the wrapper cannot grow into a general-purpose Redis client.

The interesting decision was the fallback. Every one of those controls degrades to process-local when Redis stops answering, and a store error is never converted into an allow — a limiter that disappears during a blip fails open, which is worse than the in-memory map it replaced. The honest cost is written down alongside it: at the moment Redis goes away, one caller gets a fresh local allowance.

Because a machine behaves identically whether its limits are shared or private, the property needed proving from outside. A validation script pins requests to specific instances through the platform proxy and reads back which machine answered, so “counted on one, enforced on the other” is observed, not assumed — and it ships with a self-test that proves its own judgments offline, with no cloud and no network.

Migrations
that repair

Wrote migrations that are allowed to run more than once

Data migrations are an append-only registry, each keyed by name, each in a transaction that claims its own bookkeeping row so a racing deploy loses cleanly instead of running twice. Standard practice, until one of them had to fight the schema tool.

The ORM re-renders those text date columns on every automigrate, which means the damage is repeatable while a name-keyed repair is one-shot — restore a snapshot, start a machine, and you get corrupted dates with the migration recorded as applied and a green deploy. So migrations can now be marked convergent: a repair, not a transformation, re-run every bootstrap, recording when it last did something. Two of the seven are.

The same care shows up in what they refuse to do. The migration that reduced timestamps to calendar days runs only where the original offset still exists, and refuses outright where it has already been lost, because guessing the wrong day is worse than stopping. Another enumerates the four data shapes it deliberately does not repair, each considered and rejected as unprovable, not overlooked.

Five targets,
one codebase

Shipped the same application to the browser and to four platforms

The SPA also ships as a native application on macOS, Windows, Linux and iOS through Tauri, and the native shell is 58 lines of Rust — the discipline is keeping it that way, so a platform is a build target, not a fork. Environment selection is done by build mode, with every variant file in one location shared by browser and native builds, and the release pipeline asserts that a production bundle really did bake in the production API before it will publish.

The build tooling exists because the workspace is three packages deep and the failure modes are boring but expensive: a shared library built in the wrong flavour, or a route added without a component bound to it. The second one is now a compile error, via a mapped type over the generated route ids — the kind of guard that costs an afternoon once and never has to be remembered again.

Docs that
cannot rot

Put the documentation under the same gate as the code

Across the two repositories there are 179 markdown documents, which is exactly enough for an index to quietly become fiction — and it did, once, rotting to 31 of 59 entries. Every document now opens with a status line naming its state, the date it was verified and the commit it was verified against, and where a document lives is what state it is in: finished work moves to an archive directory with a matching filename suffix. A check written in nothing but shell and grep fails the build on a missing header, an index row that disagrees with it, or a link that no longer resolves. It runs before the toolchain is even installed, because it costs a second.

The specification documents are held to a stricter standard still, and say so in their own opening: everything read from the code and not from other documents, claims carrying file and line so they can be re-derived, and explicit markers separating what was verified from what was not. Documentation in this repository has been found stale often enough that it is not treated as evidence about itself.

The same protocol governs the boundary between the API and the front end, which are developed as separate repositories that never write source into each other. Work that crosses the line becomes a hand-off document stating the direction, the decisions already made and why, the verification commands — and what not to change, which is reliably the section that saves the most time.

The product

What it looks like

The marketing site and the application, captured from running builds. Figures and payee names in the application screenshots are fictional — the layout, density and behaviour are exactly what ships.

The Fudger month view: a weekday calendar grid where each day carries a running balance badge and colour-coded income and expense rows, with a summary bar along the bottom.
The application · month viewThe calendar is the ledger, not a view of one. Every day carries its own balance — including future days — and the toolbar reads forward: next paycheck, bills due, what is left at month end. Weekends are hidden here and still counted.
The Fudger marketing site playground: three answered setup questions on the left and a generated month calendar with paydays and bills on the right.
Marketing · the playgroundThe landing page is the product, not a description of it. Three questions build a working month in the browser, with no signup and no network calls — the demo cannot leak anything because it never sends anything.
The Fudger marketing hero: large type reading “Build your month. Right now. No signup.”
Marketing · heroThe whole visual identity is CSS and SVG — the site ships zero raster images apart from a single social card.
The Fudger add-entry dialog showing title, category, entry type, amount, date, an auto-debit toggle and a recurring option.
The application · entry editorOne row is either a dated event or the template for a series. Auto-debit entries settle themselves on a nightly job that refuses to overwrite a status a human set.
The Fudger categories screen: a hierarchical table of income and expense categories with colour swatches and system badges.
The application · categoriesSystem categories ship seeded and are shared; users add their own alongside them. A category can carry a semantic role, which is how the toolbar knows which entries are a paycheck.
The Fudger month view on a phone-sized screen, with balances compacted to abbreviated amounts.
The application · mobileThe same grid at phone width, with balances compacted and safe-area insets honoured. The native iOS build renders this through the same code.
Live surfaces
  • fudger.ioMarketing site and interactive demo — live.
  • fudger.io/downloadsDesktop and mobile builds.
  • app.fudger.ioThe application — invite-only private beta, not yet public.
  • api.fudger.ioThe API — specified and gated behind a written launch runbook.
Systems owned

Sole author, three repositories

Counts taken on 2026-09-07. Generated code is excluded from every line count.

fudger-svc
REST API · Go, Ent, Postgres
290 commits · sole author
84,673 lines · 1,339 tests

The domain lives here: entries, recurrence projection, categories, declared balances, settlement, invites and admin. Layered as API, service and data, with one datastore interface between the handlers and the ORM so no handler has ever touched a query builder.

  • OpenAPI-first — 49 paths and 68 operations generating both the Go server and the TypeScript client, with 20 entity schemas generating the ORM.
  • Auth — JWT with issuer, audience and expiry validation; sessions invalidated by password change, including the race where a reset lands mid-request and would otherwise make a stolen session permanent; provider-agnostic OAuth built at the identity level so a non-OIDC provider can implement the same interface later.
  • Operability — trusted-proxy client-IP resolution instead of the router default, which reads attacker-controlled headers; instance identification ahead of every middleware that can answer a request, so rate-limit responses name their own machine; per-stage migration timeouts so a stalled release command says which stage stalled.
  • A 194-test integration suite that runs against real Postgres over real HTTP, and refuses to start unless its target is loopback — with no opt-in flag, because a flag saying “this host is fine” is exactly what gets set to make a red run green.
fudger
SPA + native clients · React 19, TypeScript, Tauri
327 commits · sole author
83,081 lines · 2,025 tests

A pnpm workspace of three packages — a shared domain and API layer, a router package, and the application — plus the Rust shell that turns the same bundle into macOS, Windows, Linux and iOS builds.

  • The client/server calculation boundary is written down explicitly: the API owns balances, and the front end owns the cascade under an anchor, three-month grid assembly, week resolution and the forward-looking aggregates. The specification lists its own divergences and dead code.
  • A design system of eight colour schemes, five layouts and three entry treatments, with the contrast contract enforced by a test that walks the whole matrix.
  • Guarded routing — a mapped type over generated route ids makes an unbound route a compile error, and search parameters are parsed as strings so an invite code is never read as exponential notation.
  • 13 end-to-end specs against a real backend, with a reporter that says which API endpoints the run touched.
fudger-mktg
Marketing site · React 19, Tailwind
Static build, no server
15,709 lines

The public front door, built around a playable demo instead of a tour or a video. The month a visitor builds is computed entirely in the browser and forgotten when the tab closes, which is both the privacy claim and the reason the claim is verifiable — the page makes no network calls at all.

  • Ten alternative landing-page prototypes from a direction bake-off are kept in the repository behind a noindex gallery, so the choice that was made can still be argued with.
  • Zero raster images by design: the identity is CSS and SVG, with a single generated social card as the exception.

The patterns in this case study are the ones the practice brings to client systems — proven somewhere the consequences land on us first.

Start a conversation