Dotty
Invoice and payment portal for freelancers. Built fast for validation - I audited it before real users showed up.
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.
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.

Click image to enlarge
1 / 7
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.
- 1Invite and invoiceThe freelancer sends the invite and invoice from the backend. Workspace data stays scoped per customer.
- 2Client signs upThe client registers with the same email that was invited. A database trigger assigns the client role.
- 3Invoice portalThey see only invoices for their workspace. Row-level security in Postgres enforces that - routing alone cannot.
- 4Pay by cardPayment happens in Stripe Checkout. Card data never touches Dotty.
- 5Marked paidThe invoice updates when a verified Stripe webhook arrives. The success page alone isn't enough to trust.
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.
Core
Supporting





