SaaS product · 2026
Ledgerline
- Full-stack
- Payments
- Product
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.
-- 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.
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
Query builder over ORM
Server components by default
No multi-currency at launch
07 — OutcomesWhere it landed
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.