SaaS product · 2026

Ledgerline

Live
  • Full-stack
  • Payments
  • Product
RoleSole engineer, design included
Duration11 weeks to first paying user
TeamSolo, with a part-time designer for the marketing site
StackNext.js, TypeScript, Postgres, Drizzle, Stripe, Resend, Vercel

01 — The problemInvoicing and payment chasing for freelancers who were running their business out of a spreadsheet and a bank app.

Freelancers get paid late because chasing an invoice feels rude, so they simply do not do it. Existing tools solve the invoice-generation half and stop there. The unsolved half is the follow-up — the polite, scheduled, unemotional reminder that nobody wants to send by hand.

02 — ConstraintsWhat I had to work inside

  • Money movement had to be someone else's regulated problem — no holding customer funds.
  • A missed or duplicated reminder email is worse than no product at all.
  • Single developer, so the operational surface had to stay small enough to debug at 2am.
  • Users import a messy client list on day one and judge the product in the first four minutes.

03 — ApproachModel the money first, the UI second

I spent the first week on the schema alone. Invoices are an append-only story — issued, viewed, part-paid, paid, voided — and the moment you let a row be mutated in place you lose the ability to answer "what did we think was true last Tuesday?". So invoices carry immutable line items and a separate event log; the current state is derived, never overwritten.

schema excerpt
-- amounts are integer minor units. never float, never numeric-in-JS.
create table invoice (
  id            uuid primary key default gen_random_uuid(),
  org_id        uuid not null references org(id),
  number        text not null,
  currency      char(3) not null,
  total_minor   bigint not null check (total_minor >= 0),
  issued_at     timestamptz,
  voided_at     timestamptz,
  unique (org_id, number)
);

-- the audit trail. append only, never updated.
create table invoice_event (
  id          bigserial primary key,
  invoice_id  uuid not null references invoice(id),
  kind        text not null,   -- issued | viewed | paid | reminded | voided
  payload     jsonb not null default '{}',
  at          timestamptz not null default now()
);

create index on invoice_event (invoice_id, at desc);

04 — ApproachMake the reminder engine boring

The scheduler is a single Postgres table and a cron route that runs every fifteen minutes. No queue broker, no worker fleet. A job row is claimed with `FOR UPDATE SKIP LOCKED`, which lets concurrent runs cooperate without a lock manager, and every send is guarded by a unique key so a retry can never produce a second email.

claim the next batch
update reminder_job j
set    status = 'running', attempts = attempts + 1, locked_at = now()
from (
  select id from reminder_job
  where  status = 'pending' and run_at <= now()
  order  by run_at
  limit  50
  for update skip locked          -- concurrent runners take disjoint rows
) picked
where j.id = picked.id
returning j.*;

Idempotency is the whole game. Each job derives a deterministic key from `(invoice_id, step)`, and the sends table has a unique constraint on it. If the process dies after the provider accepted the mail but before the row committed, the retry hits the constraint and exits clean instead of emailing the customer twice.

05 — ApproachTreat webhooks as the source of truth

The redirect back from Stripe is a UX hint, not a fact — users close the tab, networks drop, and the success page is trivially forgeable. Payment state only ever changes on a signature-verified webhook. The client-side return just polls our own API until the webhook has landed, which keeps the happy path feeling instant without trusting it.

  • Verify the signature before parsing the body — and read the raw body, not the JSON-parsed one.
  • Store the event id and reject duplicates; Stripe retries, and it will retry into your bugs.
  • Handle events out of order. `payment_intent.succeeded` can land before the checkout session completes.
  • Return 2xx fast, do the work after. A slow handler becomes a retry storm.

06 — DecisionsWhat I chose, and what it cost

Database as the job queue

ChosePostgres table + SKIP LOCKED + cron
OverRedis-backed queue with dedicated workers
BecauseOne fewer service to run, and jobs commit in the same transaction as the data they describe — so a job can never reference an invoice that rolled back.
What it costPolling adds up to 15 minutes of latency, and it will not scale past a few thousand jobs a minute. Both were fine; neither is free.

Query builder over ORM

ChoseDrizzle
OverPrisma
BecauseThe generated SQL is predictable and reviewable, migrations are plain files I can read, and there is no separate engine binary in the deploy.
What it costMore typing for deep relational reads, and I hand-wrote the recursive query for nested client hierarchies that an ORM would have generated.

Server components by default

ChoseFetch on the server, ship almost no client JS
OverClient-side data fetching with a cache library
BecauseThe list and detail views are read-heavy and personalised. Rendering them on the server removed a loading-spinner state from every screen.
What it costOptimistic UI is genuinely harder. The two screens that needed it got a client island and a manual rollback path.

No multi-currency at launch

ChoseOne currency per organisation
OverPer-invoice currency with FX at settlement
BecauseCut roughly three weeks of edge cases — rounding, rate snapshots, reporting — for a case that under a tenth of early users had.
What it costThree users churned over it. The schema keeps `currency` per invoice so the door stays open.

07 — OutcomesWhere it landed

11 wksIdea to first paymentIncluding the Stripe review period.
0Duplicate sendsAcross ~40k reminder emails, enforced by unique key.
1.1sp75 LCPMeasured on real traffic, not lab conditions.
−9 daysMedian time to paymentSelf-reported by the first 30 users.

08 — RetrospectiveWhat I would tell myself at the start

  • The scheduling half was the product. The invoice builder was table stakes and I over-built it before I understood that.
  • Deterministic idempotency keys are cheaper than any amount of retry cleverness. Decide the key up front.
  • Cutting multi-currency was correct and still cost users. Both things are true and you should say so out loud.
  • A cron-driven Postgres queue got me to production faster than the "proper" architecture would have, and I know exactly when I will have to replace it.