← Selected work
Case study

Dotty

Invoice and payment portal for freelancers and small studios. Invites, card pay, and real status in one place.

The problem

Freelancer billing looks simple right up until identity and money cross paths. The payer cannot see another client's invoices. Card data cannot sit in your database. And "paid" cannot come from a success screen in the browser. Miss one and you are leaking data or trusting the wrong thing.

What I built

Dotty is the invoice portal I built because I was done with payment status living in email threads. The freelancer runs a workspace, adds clients, sends line-item invoices, and invites people in. The client signs up with that email and lands in their own portal. Marketing stays on dotty.ai. The product lives on app.dotty.ai, which keeps the public site light and the heavier app logic where it belongs.

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

Click image to enlarge

1 / 7

Decisions that matter

Verified Stripe webhooks as the only source of "paid"

Card checkout had to work without Dotty ever touching card data, and "paid" had to be defensible. Checkout happens on Stripe. A dedicated function verifies the webhook signature and only then updates payment state. The success page does not get to make that call.

Row-level security for workspace membership

The React app uses Supabase for auth and data. Row Level Security 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 polite front-end check.

Database trigger binds invite email to the client role

Client vs freelancer role is not something you should be able to spoof with a clever API call. At signup, a Postgres trigger ties your email to an existing invite and assigns the client role. I did not want that logic living only in React where someone could skip the guard.

Mail recipients come from the database on the server

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

How it works
Client: from invite to paid
  1. 1
    Invite and invoiceThe freelancer sends the invite and invoice email from the backend. Workspace data stays scoped in the database per customer.
  2. 2
    Client signs upThey register with the same email that was invited. A database trigger assigns the client role - you cannot fake that from the UI alone.
  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; Stripe hosts the payment page.
  5. 5
    Marked paidThe invoice updates when a verified Stripe webhook arrives. The success page in the browser alone is not enough to trust.
Where the heavy lifting runs
Web app (app.dotty.ai)React SPA for day-to-day use: sign-in, dashboard, invoices. Safe reads and writes go through the database rules.
Serverless backendSupabase Edge Functions handle invites, invoice email, Checkout sessions, signed Stripe webhooks, AI reminder drafts, and sending reminders.
Secrets stay server-sideStripe, email, and AI keys live in server secrets - nothing payment-sensitive ships in the front-end bundle.
Email and paymentsResend, Stripe, and Claude are called from those functions. Recipient addresses come from the database on the server, not from form fields you could tamper with.
What matters in production

Edge functions own the dangerous paths

Heavy or secret work goes through Supabase Edge Functions: invites, invoice mail, Checkout sessions, Stripe webhooks, reminder drafts, and reminder sends. Resend runs there too. The browser does not get the dangerous paths.

Secrets stay off the client bundle

Stripe secret keys, Resend, and Anthropic keys sit in Supabase Edge secrets. Nothing payment-sensitive or AI-sensitive ships in the client bundle. No secret keys hiding in public env vars.

Forbidden looks like not found

If you shouldn't see an invoice, the API responds the same whether the ID exists or not. That cuts down on fishing for someone else's invoice IDs.

Overdue comes from the database, not from memory

Statuses follow a real lifecycle: draft, sent, paid, overdue. Overdue flips when the due date passes. It does not depend on someone remembering to check.

Auth that matches production redirects

Auth is Supabase: email and password, Google OAuth, and password reset with real production redirects wired correctly. That sounds boring. It stops being boring when the wrong origin gets involved.

One monorepo, two surfaces, shared UI tokens

Marketing runs on Astro. Product runs on React and Vite. One Turborepo keeps the shared Tailwind tokens and components in one place so the two surfaces still feel related.

Tests and runbooks before I trust a launch

Vitest covers units. Playwright covers e2e. CI runs build, lint, and tests. Runbooks cover migrations, secrets, and smoke checks on real domains. I do not trust 'it worked on my machine' with payment flows.

AI drafts for overdue reminders, never auto-sent

If an invoice is overdue, Claude can draft the reminder from the invoice context. You read it, edit whatever sounds off, then send. Nothing auto-sends. I'd rather give the user a decent draft than force one stiff template every client starts to recognize.

What I'd tighten
  • I'd add rate limits on generate-reminder and the Claude path before I scaled traffic. AI endpoints are an easy way to buy yourself an ugly bill.
  • I'd extend Playwright coverage to webhook failure and retry paths. The UI can look fine while the money path is wrong.
  • I'd standardize structured logging across Edge Functions earlier. Debugging payment issues without request correlation gets old fast.
  • I'd automate the post-deploy smoke checks that already live in the runbook. That is boring work. Which is why it should be automated.
  • If duplicate Resend sends ever showed up in production, I'd tighten idempotency on outbound mail immediately. It has not happened yet, but that is the next move.
Stack

Core

React 18TypeScriptSupabasePostgreSQLStripeEdge FunctionsResendClaude API

Supporting

ViteAstroStripe Connect
View live →