Dough
Can one designer ship the invoicing tool a studio actually needs, and hand it over whole?

01What it is
A studio that bills a mix of hourly, fixed-fee and retainer work, and gets paid through PayPal, was leaving Harvest after it moved to unpublished usage-based pricing. The two obvious replacements are Stripe-only, and this studio cannot use Stripe: its company account was locked for inactivity and reactivating it is a fight with support. So the tool that fits is one nobody sells.
Dough tracks hours against clients, projects and tasks with a timer first and manual entry second, resolves a rate for that time, turns unbilled time into a draft invoice, sends it through PayPal, which hosts the payer page and sends the client-facing email itself, and reconciles the payment when it lands. On full payment a release flow hands the client their deliverable links. It is not a SaaS product: no pricing, no signup, no multi-tenancy. It serves one business, and the strongest thing it offers over the commercial options is that the business can export everything and walk.

02The model
- 01Money is an integer count of cents, everywhere, converted at exactly two boundaries: form to store, and store to screen or PayPal. Quantity is hundredths of a unit. An invoice one cent out is a credibility problem, and floating point cannot represent most decimal fractions
- 02A client is a company and a contact is a person, many to many. Harvest forces one person who works for three companies into three indistinguishable rows, and picking the wrong one bills the wrong company. The role and the billing flag live on the join, because the same person is an owner at one company and a subcontractor at another
- 03Rates resolve down a six-level ladder, most specific wins: entry, task, project, client, user, house default. Zero is a real rate, deliberately unbilled; only a blank falls through. Once invoiced, the rate is frozen onto the entry and that copy is authoritative forever
- 04Billing is the only freezing event. The client's own words were that things are not set in stone unless they have already been billed, and three rules came out of that one sentence: unbilled time re-prices, rounding is a read-time lens that is never stored, and only sending an invoice fixes a number
- 05Partial is a load-bearing status, not polish, because PayPal fires the same paid event for fully paid, partially paid and payment-pending invoices. Paid cents is derived from payment rows, never written by a sync, and refunds live in their own column so net received is a subtraction
- 06Every invoice mutation writes an append-only event in the same transaction. Money, dates and statuses log their from and to; free text logs only that a field changed, with no hash, because low-entropy personal data makes any fingerprint dictionary-recoverable

03PayPal, the undocumented parts
Everything below was found against the sandbox and written into the decision record so nobody re-derives it. Creating an invoice returns a bare link object, not the invoice, so reading an id off it silently gives nothing. The payer URL lives under metadata, not in the links, and the links set changes with the invoice status. There is no fourteen-day payment term even though fourteen days is the studio's house default. There are nine webhook event types, not the six I assumed. And the invoice id in a webhook is nested one level deeper than it looks; a handler guarding on the wrong level does nothing, returns 200, and PayPal never retries, so the payment is lost with no error anywhere.
The one that changed the architecture: PayPal never delivers a cancellation webhook. Verified twice with a reachable endpoint and zero events either time. So the reconcile script, which I had written as a maintenance tool, is the only path for cancellations and has to run on a schedule rather than by hand.

04Decisions I wrote down, including the wrong ones
- 01SQLite lost to Postgres on Neon once it was clear the users would never run the app themselves. The first decision record is kept in the repo marked superseded, with the wrong assumption named, rather than deleted. Neon over Supabase because Supabase pauses a free project after a week idle and a freelancer invoices in monthly bursts; the cost was building auth by hand
- 02Adobe Spectrum as the component layer, with the reasoning flagged as an assumption in the record itself: the users work in Adobe products all day, so the patterns should feel familiar, and nobody has asked them. The lock-in is stated too. There is no retheming path, the escape is a rewrite, and the record says we expect that rewrite
- 03Light theme pinned after Spectrum picked dark from the OS by accident. A table of money in columns is not artwork, and the owner has spent eleven years in a light tool
- 04PayPal was chosen for a non-technical reason and the record argues against itself: a study of over a thousand freelancer posts found payment-rail preference barely registers as a pain point, and the record says so, then says that does not change the decision because reversal was never available
- 05The client-facing release email was scoped as a separate Gmail send with its own standing credential, and was folded into the one transport the internal nudges already use. Two credential surfaces became one
- 06About ten clients and ten projects means no search, no pagination and no virtualised pickers anywhere. Building them would make the common case slower
05What went wrong
Two dev servers sharing one build directory overwrite each other's artifacts, and the failures do not name their cause: raw unstyled HTML, phantom missing modules, dropped CSS, dead components, all of which look like application bugs. Three lanes reproduced the same site-wide 500s independently before the shared cache was found. The fix is a per-server build directory and a recovery recipe that is now the first thing anyone tries.
A bisect proved that a money-parsing change had broken a page. It had not. Requests landing mid-recompile return a 500 that looks exactly like a defect, and the fix was one edit away from replacing the ruled money boundary with a lookalike regex for a fault that never existed. The house rule that came out of it: verify the result, not the return value. A synthetic key press returned the opposite answer to a real one, and a programmatic click opened a menu whose dismissal was dead, which is why the end-to-end suite uses trusted input events only.
An open redirect on the login's next parameter was found, fixed by re-validating the reconstructed path, and closed by the security lane. It mattered more than it looked, because the planned release flow would have reintroduced the same vector, and that flow was redesigned around it.
06How it was built
By a fleet of agents with one designer as the product manager: a PM lane plus backend, UI, QA, Figma, interaction and security lanes, each with a handoff brief a successor can cold-start from. The Figma file is the build contract, decisions live in a written register, and every finding goes to disk beside its source rather than into chat.
The method notes from that setup run to a few thousand lines. The short version: measure before acting, put a known-good control in every sweep, and treat a divergence report as a claim rather than a finding.
709
commits in eleven days, seven lanes
17
tables, 22 hand-written migrations, 62 end-to-end specs
2
boundaries where money is ever converted
07What is next
- 01The release flow, the endpoint that hands a paid client their files, is a reviewed sketch that pivoted from a token page to an auth-gated deep link so the open-redirect vector stays closed
- 02Status columns are strings, a holdover from SQLite. Postgres enums are worth doing as their own change, not as a side effect of the database move
- 03A retry loop for failed outbound email. The schema is retry-ready; the loop is a follow-up
- 04Per-developer database branches, so a reseed can never point at production