← Selected work
Case study

Dotty

Invoice and payment portal for freelancers. Built fast for validation - I audited it before real users showed up.

The problem

A product built fast can look fine right up until the first real payment. Freelancer billing is where identity and money cross paths - that's where the gaps hide. The payer cannot see another client's invoices. Card data shouldn't touch your database. "Paid" still can't mean whatever the success screen said. Miss one of those and you leak data or trust the wrong signal. The audit scored the app at 68/100. Not ready for live production payments.

What I did

Dotty is a freelancer invoice and payment portal. The app was built fast to validate the concept. I came in before launch to find what would break once real users showed up.

Two findings were critical. Stripe Checkout had no idempotency key - open the same invoice in two tabs, click Pay Now twice, and you could get two charges. The webhook was idempotent after the fact, but it couldn't stop two separate Checkout sessions from both going through. The second critical finding was .env files sitting in the repo tree. Not tracked, but present - and anything ever committed needed rotation.

The high-severity list was still long. Invoice numbering used MAX(invoice_number) + 1, which collides under concurrent inserts in the same workspace. Date parsing used new Date('YYYY-MM-DD'), which renders a day behind in US Pacific - due dates showing as Mar 31 when the invoice said Apr 1. Five browser-invoked Edge Functions had no CORS or OPTIONS handling, which breaks preflight in production. Thirty-seven dependency vulnerabilities, seventeen of them high.

I fixed what was in scope and left a written report with findings ranked by severity.

Screenshots
Marketing split from the app - SEO surface without shipping secrets in the bundle.

Click image to enlarge

1 / 7

Decisions that matter

Stripe idempotency and single-session enforcement

Checkout now claims the invoice atomically before creating a session. If an active session exists, the second request gets the existing URL instead of a new charge. The idempotency key is tied to the invoice ID. Two clicks, one charge.

Verified Stripe webhooks as the only source of "paid"

Card checkout happens on Stripe. A dedicated Edge Function verifies the webhook signature and only then updates payment state. The success page in the browser doesn't get to make that call.

Row-level security for workspace membership

RLS decides what you can see based on workspace membership. Rules for money and identity live in Postgres and on the webhook path - not in a frontend check someone could skip.

Database trigger binds invite email to the client role

Client vs freelancer role is not something you can spoof with a clever API call. At signup, a Postgres trigger ties your email to an existing invite and assigns the client role. That logic isn't React-only.

Mail recipients come from the database on the server

When invoice or reminder mail goes out, the recipient address comes from the database, not from whatever the client posted in the request body. That's the same bar you'd want on any system where someone might try to reroute mail.

Atomic send-invoice to prevent duplicate emails

The send flow claims draft status atomically before sending. A parallel request that arrives a millisecond later finds nothing to claim and exits cleanly. One email, one status change.

How it works
Client: from invite to paid
  1. 1
    Invite and invoiceThe freelancer sends the invite and invoice from the backend. Workspace data stays scoped per customer.
  2. 2
    Client signs upThe client registers with the same email that was invited. A database trigger assigns the client role.
  3. 3
    Invoice portalThey see only invoices for their workspace. Row-level security in Postgres enforces that - routing alone cannot.
  4. 4
    Pay by cardPayment happens in Stripe Checkout. Card data never touches Dotty.
  5. 5
    Marked paidThe invoice updates when a verified Stripe webhook arrives. The success page alone isn't enough to trust.
What matters in production

Edge Functions own the dangerous paths

Invites, invoice mail, Checkout sessions, Stripe webhooks, reminder drafts - all of it runs server-side. The browser doesn't get the dangerous paths.

Secrets stay off the client bundle

Stripe, Resend, and Anthropic keys live in Supabase Edge secrets. Nothing payment-sensitive ships in the client bundle.

Forbidden looks like not found

If you should not see an invoice, the API responds the same whether the ID exists or not.

AI drafts for overdue reminders, never auto-sent

Claude drafts the reminder from the invoice context on the server - not from whatever the client sends. You read it, edit it, then send. Nothing auto-sends.

Stack

Core

React 18TypeScriptSupabasePostgreSQLStripeEdge FunctionsResendClaude API

Supporting

ViteAstroStripe Connect
View live →