Case study · 02 / 06 · 20268 min read
  • Next.js
  • Postgres
  • PayPal
  • Prisma
  • Spectrum

Dough

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

Where
Time tracking and PayPal invoicing for a design studio. Single tenant, running in a sandbox, built to be handed over.
Role
Product, design, build, and the handover plan
A Dough invoice on seed data: two time lines, a partial payment, the balance in large type, and the buttons to record a payment, copy the payment link or open it in PayPal
A working app in a sandbox with a working export, so the studio can take the whole thing and leave; no client billed through it yet

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. Nobody sells the tool that fits.

It has no pricing or signup and it serves one business

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 is meant to hand the client their deliverable links; today that flow is a reviewed sketch. It has no pricing or signup and it serves one business, and the strongest thing it offers over the commercial options is that the business can export everything and walk. Handover means the studio gets the repository and a live site of its own, signs in through its own OAuth, and takes it from there. Nothing stays with me. It has not billed a real client yet. It runs in a sandbox with seed data, and it has not moved since the layoff, because the portfolio has had the time.

The Dough dashboard on seed data: hours logged today, the outstanding balance across four invoices, a Needs attention list of overdue and sent invoices with the amount outstanding on each, and one draft not yet sent
The dashboard, on seed data. Two numbers and a list of what needs attention: overdue first, then sent, then drafts.

02The model

  1. 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
  2. 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
  3. 03Rates resolve down a six-level ladder, most specific wins: entry, task, project, client, user, house default. Zero is a real rate and bills nothing. Only a blank falls through. Once invoiced, the rate is frozen onto the entry and that copy is authoritative forever
  4. 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
  5. 05Partial has to be its own status, 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
  6. 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
The invoices list on seed data, grouped by what to do next: needs attention with overdue and sent invoices and their outstanding amounts, not yet sent with one draft, and settled with one paid invoice
Invoices grouped by what to do about them instead of by date. Overdue is red, sent is blue, draft is grey, paid is green, and the amount beside each is what is still owed.

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 is under metadata and 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 reconcile script, which I had written as a maintenance tool, is the only path for cancellations

PayPal never delivers a cancellation webhook, and that changed the architecture. 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.

One sent invoice on seed data: the balance in large type, Record a payment, Copy payment link and Open in PayPal buttons, one time line at quantity times rate, the subtotal, payments and balance, an empty deliverables panel, and a history column showing created and sent through PayPal with the due date
A sent invoice. The balance is the number that matters, so it is the big one. Payments are recorded here or arrive from PayPal, and the history on the right is the append-only event log rendered as a list.

04Decisions I wrote down, including the wrong ones

  1. 01SQLite lost to Postgres on Neon once it was clear the users would never run the app on their own machines. 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. The auth and the webhook handler were written and reviewed by the agent lanes, including the security lane that found the open redirect, and no human has read either end to end. That is the first thing that changes before a real client's money moves through it.
  2. 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. Nobody has asked them. Dough was not commissioned, I built it for them, and what I asked about was what it needed to do, not how it should look. The look can change later, at a cost. The lock-in is stated too. There is no retheming path, the escape is a rewrite, and the record says we expect that rewrite
  3. 03Light theme pinned after Spectrum picked dark from the OS by accident. A table of money in columns does not need a dark theme, and the owner has spent eleven years in a light tool
  4. 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
  5. 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
  6. 06About ten clients and ten projects means no search or 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.

the fix was one edit away from replacing the ruled money boundary with a lookalike regex for a fault that never existed

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 rule that came out of it is to check the actual result, not just 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, so 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 is to measure before acting and to put a known-good control in every sweep. A divergence report is a claim until it is confirmed.

  • 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

  1. 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
  2. 02Status columns are strings, a holdover from SQLite. Postgres enums should be their own change
  3. 03A retry loop for failed outbound email. The schema is retry-ready. The loop is a follow-up
  4. 04Per-developer database branches, so a reseed can never point at production