Case study
LeadFlow
An all-in-one CRM, booking, quoting and payments app for small service businesses, with role-based portals, verified online payments, and AI-drafted follow-ups streamed into the lead view.

The problem
Cleaners, photographers, tutors and consultants usually run their business across WhatsApp, email, a calendar and a spreadsheet. Enquiries go cold, quotes are retyped into invoices, and customers have to ask for every update.
LeadFlow puts that whole loop in one product, with three roles: owners and staff work the pipeline, and customers get a portal to see appointments, accept quotes and pay invoices. I scoped AI to one job where it clearly saves time — summarising an enquiry and drafting the follow-up — rather than spreading it across the app.
The revenue loop
- LeadCaptured into a pipeline
- Follow-upAI-drafted, human-sent
- AppointmentDaily reminder job
- QuoteDraft → sent → accepted
- InvoiceOne per accepted quote
- PaymentRazorpay, verified twice
Architecture
One Next.js app, no separate backend
Next.js 16 App Router
- Server componentsRead through a role-checked data layer
- Server actionsZod-validated writes + audit log
- Route handlersAI, payments, uploads, cron
Drizzle ORM
Services
- Neon Postgres15 tables, CHECK constraints
- RazorpayOrders, verify, webhook
- OpenAIgpt-4o-mini via AI SDK
- ResendReact Email templates
- CloudinarySigned direct uploads
Hard problems
1. Payments that can’t be spoofed
The browser reports a successful checkout, but the server never trusts it. Verification checks that the invoice belongs to the signed-in customer and that Razorpay’s HMAC signature matches. A separate webhook validates the raw request body and marks the invoice paid even if the customer closes the tab before the client callback fires.
const invoice = await db.query.invoices.findFirst({
where: eq(invoices.id, invoiceId),
with: { customer: true },
});
if (!invoice || invoice.customer?.userId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const isValid = validatePaymentVerification(
{ order_id: razorpay_order_id, payment_id: razorpay_payment_id },
razorpay_signature,
process.env.RAZORPAY_KEY_SECRET ?? "",
);
if (!isValid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
app/api/razorpay/verify/route.ts
Unique indexes on the Razorpay payment ID and on quote_id (one invoice per quote) back this up at the database level.

2. AI drafts grounded in real context
The draft-email route loads the lead and the customer’s five most recent messages, then streams plain text straight into the UI, so the first words appear quickly. Staff edit and send the draft themselves — the model never contacts a customer directly.
const result = streamText({ model: openai("gpt-4o-mini"), prompt });
return result.toTextStreamResponse();
app/api/ai/draft-email/route.ts — the prompt includes status, original enquiry and message history
3. One codebase, three audiences
A requireRole() helper in a server-only data layer guards every page, action and route handler, redirecting users to the area their role allows. Customers only ever query rows linked to their own user ID.

Outcome
- A deployed, seeded demo with separate owner, staff and customer accounts, covering the full loop from enquiry to paid invoice in Razorpay test mode.
- Money stored as
numeric(12,2)with database CHECK constraints on amounts and quantities. - Quote and invoice totals computed on the server, never trusted from the form.
- Six React Email templates: welcome, lead follow-up, appointment confirmation and reminder, quote sent, invoice sent.
What I’d change
This was built fast, and the code review I did afterwards is the most useful part of the project: