Patient chart
Ledgerly
Invoicing for freelancers. Fictional app, realistic findings.
- Stack
- Next.js App Router, TypeScript, Supabase, Stripe
- Built with
- Lovable, then Cursor
- Size
- 8,900 lines
- Review
- Launch Review
- Diagnosis
- Triage red. Treat the three problems under "Treat today" first.
Triage
Slop Medic
- Patient
- Ledgerly (fictional)
- Size
- 8,900 lines
Slop score61
| Problem | Urgency |
|---|---|
| Open database tables 3 | Critical |
| Fakeable payment webhooks 1 | Critical |
| Unchecked user input 4 | High |
| Exposed secret keys 0 | None |
| any types 143 | Medium |
| Swallowed errors 12 | Medium |
| Copy-pasted code 14% | Low |
| Tests 6% | High |
Slop score: 0 is clean, 100 is pure slop. Your report says where each problem is and how to fix it.
Red: fix these first.
How this review was made
- Software
- Automated checks for leaked keys, vulnerable and made-up packages, type errors and common security patterns, then an AI pass over the whole codebase that produced 38 candidate problems.
- Human
- Every candidate checked in the code. 9 were false alarms and removed. 3 problems the tools missed were added, including the Stripe webhook.
- Proof
- Both critical problems confirmed on the live app with two test accounts. No real user data was changed.
- Human time
- 3 hours 40 minutes, including the proof and the call
Summary
Ledgerly works, and the invoicing logic itself is better than most AI-built apps I see. The problems are in what surrounds it.
Three issues need fixing before you onboard more customers: any signed-in user can read and edit every other customer’s invoices, the Stripe webhook can be faked to mark any invoice as paid, and the create-invoice action accepts whatever the browser sends. All three are small, contained fixes. Everything else can wait for a quiet week.
Treat today
Any signed-in user can read and edit every invoice
supabase/migrations/0004_invoices.sql:18The select and update policies on invoices allow every row. Your publishable key (the anon key, in older projects) is public by design, so anyone with an account can list all invoices of all customers from the browser console, including names, addresses and amounts.
Proven on the live app: I signed up two test accounts. From the browser console, account B listed every invoice of account A, including the customer address. Screenshot in the full report.
create policy "Invoices are readable"
on invoices for select to authenticated
Removed: using (true);
Added: using (auth.uid() = owner_id);
Treatment: Restrict select, update and delete to the owner, then repeat for invoice_items and customers. Takes about an hour including a test with two accounts.
In supabase/migrations, add a new migration that replaces the select, update and delete policies on invoices, invoice_items and customers so each only allows rows where owner_id = auth.uid(). For invoice_items, check ownership through the parent invoice. Do not change any application code. List every policy you changed.
Signed
The Stripe webhook believes anyone
app/api/webhooks/stripe/route.ts:4The webhook parses the request body without checking Stripe’s signature. Anyone who finds the URL can send a fake checkout.session.completed event and mark any invoice as paid.
Proven on the live app: A hand-written request to the webhook URL marked one of my test invoices as paid. No money changed hands. Request and screenshot in the full report.
export async function POST(req: Request) {
Removed: const event = await req.json()
Added: const signature = req.headers.get('stripe-signature') ?? ''
Added: const event = stripe.webhooks.constructEvent(
Added: await req.text(), signature, env.STRIPE_WEBHOOK_SECRET
Added: )
if (event.type === 'checkout.session.completed') {
Treatment: Verify the signature with the raw body and your webhook secret, and return 400 when it fails.
In app/api/webhooks/stripe/route.ts, verify the Stripe signature before handling any event: read the raw body with req.text(), call stripe.webhooks.constructEvent with the stripe-signature header and STRIPE_WEBHOOK_SECRET, and return a 400 response if verification throws. Keep the existing event handling unchanged.
Signed
Creating an invoice trusts the whole request
app/invoices/actions.ts:12The server action inserts whatever object the form sends. A user can set owner_id to someone else, or create an invoice that is already marked as paid.
'use server'
Removed: export async function createInvoice(data: any) {
Removed: return supabase.from('invoices').insert(data)
Added: export async function createInvoice(input: unknown) {
Added: const { customerId, lines } = InvoiceInput.parse(input)
Added: const user = await requireUser()
Added: return supabase.from('invoices').insert({
Added: customer_id: customerId, lines, owner_id: user.id, status: 'draft'
Added: })
}
Treatment: Validate the input with a schema and set owner and status on the server. The same pattern applies to updateInvoice and createCustomer.
In app/invoices/actions.ts, change createInvoice to accept unknown, parse it with a zod schema that only allows customerId and lines, get the user from the session, and insert owner_id = user.id and status = 'draft' on the server. Apply the same pattern to updateInvoice and createCustomer. Add the schemas to lib/validation.ts.
Signed
Treat when you can
The dashboard runs one query per customer
app/dashboard/page.tsx:31Totals are loaded one customer at a time, so an account with 200 customers makes 201 database calls on every page load. It feels fine today and gets slower with every customer. Replace it with one grouped query.
Strict mode is off and any is everywhere
tsconfig.json:7strict is false and there are 143 uses of any, most at the boundary where data comes in. TypeScript is not checking the parts most likely to break. Turn strict on and fix the types at the edges first.
A deprecated auth package
package.json@supabase/auth-helpers-nextjs is deprecated and no longer supported. Fixes and security updates go to its replacement, @supabase/ssr.
Three date libraries
package.jsonmoment, date-fns and dayjs are all installed and all used. Keep date-fns, remove the other two and about 80 KB of compressed JavaScript from every page.
Healthy
- Sign-in with Supabase Auth is set up correctly, including email confirmation and session refresh.
- Stripe Checkout itself is integrated the standard way. Only the webhook needs work.
- Tax and rounding in the invoice totals are correct and covered by the few tests that do exist.
- The component structure is consistent enough that the fixes above will not ripple through the UI.
Discharge instructions for your AI
Paste these into your Lovable knowledge, Cursor rules or CLAUDE.md so the next session doesn’t bring the same problems back.
- Every server action and route handler validates its input with a schema from lib/validation.ts.
- Never use the secret key (the service role key, in older projects) outside supabase/functions.
- Every new table gets row level security and an owner policy in the same migration.
- No new dependencies without asking first.The full report continues with 25 more problems, the call notes and a fix order. Once the three problems above are fixed, Ledgerly gets a free follow-up check. Book a review to get one for your app.