Build a complete client invoicing tool in Lovable with dynamic line items, a live invoice preview, PDF generation via Edge Function, and automated overdue detection. Supabase handles clients, invoices, and line items with status tracking from draft through paid — giving you a professional billing system without any manual backend setup.
What you're building
A client invoicing tool automates the most time-consuming part of freelance or agency billing: creating, formatting, sending, and tracking invoices. Instead of building invoices in Google Docs or Word, your team creates them in a form that auto-calculates totals, generates a professional PDF, and emails it to the client.
The most technically interesting part of this build is the dynamic line items form. Using React Hook Form's useFieldArray, you can add and remove line item rows (description, quantity, unit price) while the subtotal, tax, and total update in real time in a preview panel next to the form. This feels polished and saves the mental math of manual totaling.
PDF generation runs in a Supabase Edge Function on Deno. The function receives the invoice data, builds an HTML string matching the invoice design, and converts it to PDF. The PDF URL is stored in the invoices table and made available for download.
Final result
A professional invoicing system where you can create formatted invoices with live preview, generate PDFs, email clients, and track payment status automatically.
Tech stack
Prerequisites
- A Lovable account (Pro plan required for Edge Functions)
- A Supabase project connected to your Lovable project via Cloud tab
- A Resend account for email delivery (free tier: 100 emails/day, resend.com)
- Your business name, address, and logo URL ready for the invoice header
- Optional: a list of your existing clients ready to import as seed data
Build steps
Create the database schema for clients, invoices, and line items
The schema has three core tables: clients, invoices, and line_items. The invoice status lifecycle goes from draft to sent, then either paid, overdue, or cancelled. A calculated total column is maintained by a trigger so you never manually sum line items in the app.
1-- Run in Supabase SQL Editor23CREATE TYPE invoice_status AS ENUM ('draft', 'sent', 'paid', 'overdue', 'cancelled');45CREATE TABLE clients (6 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,7 user_id UUID REFERENCES auth.users NOT NULL,8 company_name TEXT NOT NULL,9 contact_name TEXT,10 email TEXT NOT NULL,11 phone TEXT,12 address TEXT,13 city TEXT,14 country TEXT,15 tax_number TEXT,16 notes TEXT,17 created_at TIMESTAMPTZ DEFAULT now()18);1920CREATE TABLE invoices (21 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,22 user_id UUID REFERENCES auth.users NOT NULL,23 client_id UUID REFERENCES clients(id) NOT NULL,24 invoice_number TEXT NOT NULL,25 status invoice_status DEFAULT 'draft',26 issue_date DATE NOT NULL DEFAULT CURRENT_DATE,27 due_date DATE NOT NULL,28 subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,29 tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,30 tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,31 total DECIMAL(12,2) NOT NULL DEFAULT 0,32 notes TEXT,33 pdf_storage_path TEXT,34 sent_at TIMESTAMPTZ,35 paid_at TIMESTAMPTZ,36 created_at TIMESTAMPTZ DEFAULT now(),37 updated_at TIMESTAMPTZ DEFAULT now()38);3940CREATE TABLE line_items (41 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,42 invoice_id UUID REFERENCES invoices(id) ON DELETE CASCADE NOT NULL,43 description TEXT NOT NULL,44 quantity DECIMAL(10,2) NOT NULL DEFAULT 1,45 unit_price DECIMAL(12,2) NOT NULL,46 amount DECIMAL(12,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,47 position INTEGER NOT NULL DEFAULT 048);4950-- Trigger to update invoice totals when line_items change51CREATE OR REPLACE FUNCTION update_invoice_totals()52RETURNS TRIGGER LANGUAGE plpgsql AS $$53DECLARE54 v_subtotal DECIMAL(12,2);55 v_tax_rate DECIMAL(5,2);56BEGIN57 SELECT COALESCE(SUM(quantity * unit_price), 0) INTO v_subtotal58 FROM line_items WHERE invoice_id = COALESCE(NEW.invoice_id, OLD.invoice_id);59 60 SELECT tax_rate INTO v_tax_rate FROM invoices61 WHERE id = COALESCE(NEW.invoice_id, OLD.invoice_id);62 63 UPDATE invoices SET64 subtotal = v_subtotal,65 tax_amount = ROUND(v_subtotal * v_tax_rate / 100, 2),66 total = v_subtotal + ROUND(v_subtotal * v_tax_rate / 100, 2),67 updated_at = now()68 WHERE id = COALESCE(NEW.invoice_id, OLD.invoice_id);69 70 RETURN NEW;71END;72$$;7374CREATE TRIGGER line_items_changed75AFTER INSERT OR UPDATE OR DELETE ON line_items76FOR EACH ROW EXECUTE FUNCTION update_invoice_totals();7778-- RLS79ALTER TABLE clients ENABLE ROW LEVEL SECURITY;80ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;81ALTER TABLE line_items ENABLE ROW LEVEL SECURITY;8283CREATE POLICY "Users manage own clients" ON clients FOR ALL USING (user_id = auth.uid());84CREATE POLICY "Users manage own invoices" ON invoices FOR ALL USING (user_id = auth.uid());85CREATE POLICY "Users manage own line items" ON line_items FOR ALL USING (86 EXISTS (SELECT 1 FROM invoices WHERE id = invoice_id AND user_id = auth.uid())87);Pro tip: The GENERATED ALWAYS AS column on line_items.amount means the database always calculates quantity * unit_price — you can never accidentally store a wrong amount. The trigger then sums these up to keep invoices.total accurate without any application logic.
Expected result: Three tables created with RLS enabled. The trigger function appears in Supabase Dashboard under Database → Functions. Adding a test line item via SQL Editor and checking the invoices table shows the total updated automatically.
Build the invoice creation form with dynamic line items
The invoice creation form is the core of the tool. Use React Hook Form's useFieldArray for dynamic line item rows. As users type quantities and prices, the totals update in real time. The form and a live preview card sit side by side.
1Build the invoice creation page at /invoices/new with a two-column layout:23LEFT COLUMN (form, 55% width):41. Invoice header fields:5 - Client select (shadcn Select, fetches from clients table, shows company name + email)6 - Invoice number (auto-generated: INV-{year}-{sequential number}, editable)7 - Issue date (shadcn Calendar Popover, default today)8 - Due date (shadcn Calendar Popover, default 30 days from today)9 - Tax rate percentage (number input, default 0)10112. Line items section with useFieldArray:12 - Each row: Description (text input, flex-grow), Quantity (number, 80px), Unit Price (number, 120px), Amount (calculated, 100px, read-only), Remove button (X)13 - "Add Line Item" button adds a new row at the bottom14 - Minimum 1 line item required (show error if empty on submit)15 - Amount column = quantity * unit_price calculated in real time16173. Totals summary (right-aligned below line items):18 - Subtotal: sum of all amounts19 - Tax ({tax_rate}%): subtotal * tax_rate / 10020 - Total: subtotal + tax (bold, larger text)21224. Notes textarea (optional)235. Submit buttons: "Save as Draft" and "Save and Preview"2425Right Column (preview): see next step2627Use react-hook-form with zod schema. Validate: client required, at least 1 line item, quantity > 0, unit_price >= 0, due_date after issue_date.Pro tip: For the auto-generated invoice number, query the count of existing invoices for the current user and format as INV-{currentYear}-{count + 1} padded to 4 digits (e.g., INV-2025-0042). Do this in a useEffect when the form loads.
Expected result: The invoice form renders with one default empty line item row. Adding a quantity and price immediately updates the totals. The Add Line Item button adds new rows. The Remove button on a row removes it and recalculates totals.
Build the live invoice preview card
The right column shows a live preview of the invoice as it's being filled in. This preview matches the final PDF layout: your business header, client details, line items table, and totals. It uses shadcn Card and updates reactively as form values change.
1Build a live invoice preview component that displays in the right column of the invoice form:23Preview layout (styled like a real invoice document, white background, subtle shadow):41. Header row:5 - Left: Your business name (bold, large) + address placeholder text6 - Right: "INVOICE" label (large, primary color) + Invoice Number + Issue/Due dates782. Bill To section:9 - Client company name (populated from selected client)10 - Contact name, email, address (populated from client record)11123. Line items table:13 - Column headers: Description, Qty, Unit Price, Amount14 - One row per line item from the form (live-updating as user types)15 - Alternating row background: white and gray-5016174. Totals section (right-aligned):18 - Subtotal row19 - Tax row (hidden if tax_rate = 0)20 - Bold total row with border-top21225. Notes section at bottom (if notes field has content)236. Footer: payment terms placeholder + "Thank you for your business"2425Watch the form values using react-hook-form's watch() function and pass them as props to the preview component. The preview updates on every keystroke without any extra state management.Expected result: As the user types in the form, the preview panel updates instantly. Selecting a client populates the Bill To section. Adding line items shows them in the preview table. The preview closely matches what the final PDF will look like.
Create the PDF generation Edge Function
When the user clicks Send Invoice or Download PDF, an Edge Function receives the invoice data, builds an HTML representation, converts it to PDF using a Deno-compatible library, saves it to Supabase Storage, and returns the public URL.
1// supabase/functions/generate-invoice-pdf/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8};910serve(async (req) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });1213 try {14 const supabase = createClient(15 Deno.env.get('SUPABASE_URL')!,16 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!17 );1819 const { invoice_id } = await req.json();2021 const { data: invoice } = await supabase22 .from('invoices')23 .select('*, clients(*), line_items(* order by position)')24 .eq('id', invoice_id)25 .single();2627 if (!invoice) throw new Error('Invoice not found');2829 const lineItemsHtml = invoice.line_items.map((item: Record<string, unknown>) => `30 <tr style="border-bottom: 1px solid #eee;">31 <td style="padding: 8px 4px;">${item.description}</td>32 <td style="padding: 8px 4px; text-align: right;">${item.quantity}</td>33 <td style="padding: 8px 4px; text-align: right;">$${Number(item.unit_price).toFixed(2)}</td>34 <td style="padding: 8px 4px; text-align: right;">$${Number(item.amount).toFixed(2)}</td>35 </tr>36 `).join('');3738 const html = `<!DOCTYPE html><html><head><meta charset="UTF-8">39 <style>body{font-family:Arial,sans-serif;color:#333;padding:40px;max-width:800px;margin:0 auto;}40 .header{display:flex;justify-content:space-between;margin-bottom:32px;}41 table{width:100%;border-collapse:collapse;}42 th{text-align:left;padding:8px 4px;border-bottom:2px solid #333;font-size:12px;text-transform:uppercase;color:#666;}43 .totals{margin-top:24px;text-align:right;} .total-row{font-weight:bold;font-size:18px;border-top:2px solid #333;padding-top:8px;}44 </style></head><body>45 <div class="header">46 <div><h2>Your Business Name</h2></div>47 <div style="text-align:right;"><h1 style="color:#2563eb;margin:0;">INVOICE</h1>48 <p>Invoice #: ${invoice.invoice_number}</p>49 <p>Date: ${invoice.issue_date}</p><p>Due: ${invoice.due_date}</p></div>50 </div>51 <div><strong>Bill To:</strong><br/>52 ${invoice.clients.company_name}<br/>53 ${invoice.clients.contact_name || ''}<br/>54 ${invoice.clients.email}</div>55 <table style="margin-top:32px;">56 <thead><tr><th>Description</th><th style="text-align:right;">Qty</th><th style="text-align:right;">Unit Price</th><th style="text-align:right;">Amount</th></tr></thead>57 <tbody>${lineItemsHtml}</tbody>58 </table>59 <div class="totals">60 <p>Subtotal: $${Number(invoice.subtotal).toFixed(2)}</p>61 ${invoice.tax_rate > 0 ? `<p>Tax (${invoice.tax_rate}%): $${Number(invoice.tax_amount).toFixed(2)}</p>` : ''}62 <p class="total-row">Total: $${Number(invoice.total).toFixed(2)}</p>63 </div>64 ${invoice.notes ? `<p style="margin-top:32px;color:#666;">${invoice.notes}</p>` : ''}65 </body></html>`;6667 const pdfResponse = await fetch('https://api.pdfmonkey.io/api/v1/documents', {68 method: 'POST',69 headers: {70 'Authorization': `Bearer ${Deno.env.get('PDFMONKEY_API_KEY')}`,71 'Content-Type': 'application/json'72 },73 body: JSON.stringify({ document: { document_template_id: 'html', payload: { html } } })74 });7576 if (!pdfResponse.ok) {77 const htmlBlob = new Blob([html], { type: 'text/html' });78 const storagePath = `invoices/${invoice_id}/invoice-${invoice.invoice_number}.html`;79 await supabase.storage.from('invoice-exports').upload(storagePath, htmlBlob, { upsert: true });80 const { data: { publicUrl } } = supabase.storage.from('invoice-exports').getPublicUrl(storagePath);81 await supabase.from('invoices').update({ pdf_storage_path: storagePath }).eq('id', invoice_id);82 return new Response(JSON.stringify({ url: publicUrl, format: 'html' }), {83 headers: { ...corsHeaders, 'Content-Type': 'application/json' }84 });85 }8687 return new Response(JSON.stringify({ success: true }), {88 headers: { ...corsHeaders, 'Content-Type': 'application/json' }89 });90 } catch (err) {91 return new Response(JSON.stringify({ error: err.message }), {92 status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }93 });94 }95});Pro tip: For a free PDF solution without a third-party API, the Edge Function can return HTML and you can use the browser's window.print() with print-specific CSS on the invoice preview component. This works surprisingly well for simple invoice designs.
Expected result: Clicking 'Download PDF' calls the Edge Function, which generates the PDF content and returns a URL. The browser opens the download. The invoice record in Supabase shows the pdf_storage_path populated.
Build the invoice DataTable and add overdue detection
The main invoices list shows all invoices with status badges. A background check on page load marks overdue invoices (due_date has passed, status is 'sent'). Add a send invoice flow that emails the client and updates the status to 'sent'.
1Build the invoices list page at /invoices:231. Header with "New Invoice" button and stats row (4 cards):4 - Total Invoiced (sum of all invoice totals)5 - Paid (sum of paid invoices this month)6 - Outstanding (sum of sent invoices not yet paid)7 - Overdue (count of overdue invoices, red badge if > 0)892. shadcn DataTable with columns:10 - Invoice Number (monospace, clickable)11 - Client (company name)12 - Issue Date13 - Due Date (red if overdue)14 - Status (shadcn Badge: draft=gray, sent=blue, paid=green, overdue=red, cancelled=gray)15 - Total Amount (right-aligned)16 - Actions (View, Send, Download PDF, Mark as Paid, Delete)17183. Overdue detection: on page load, run this query in Supabase:19 UPDATE invoices SET status = 'overdue' WHERE status = 'sent' AND due_date < CURRENT_DATE AND user_id = {currentUserId}20 Do this as an RPC call or direct .update() with a filter.21224. Send Invoice flow (clicking Send action):23 - Opens a Dialog confirming: "Send invoice {number} to {client email}?"24 - On confirm: call a Supabase Edge Function 'send-invoice-email' passing invoice_id25 - Edge Function sends email via Resend with HTML invoice content26 - Update invoice status to 'sent', set sent_at = now()27 - Show success toast: "Invoice sent to {email}"28295. Mark as Paid: opens a Dialog with paid date selector. Updates status='paid', paid_at=selected_date.3031Add a filter row above the table: Status dropdown, Client select, Date range picker.Expected result: The invoices list shows all invoices with correct status badges. Any sent invoices with past due dates immediately show 'overdue' status on page load. The Send button sends an email and updates the status to 'sent'.
Complete code
1import { Separator } from '@/components/ui/separator';23interface LineItem {4 quantity: number;5 unit_price: number;6}78interface InvoiceTotalsProps {9 lineItems: LineItem[];10 taxRate: number;11 className?: string;12}1314function formatCurrency(amount: number): string {15 return new Intl.NumberFormat('en-US', {16 style: 'currency',17 currency: 'USD',18 minimumFractionDigits: 2,19 }).format(amount);20}2122export function InvoiceTotals({ lineItems, taxRate, className = '' }: InvoiceTotalsProps) {23 const subtotal = lineItems.reduce((sum, item) => {24 const qty = Number(item.quantity) || 0;25 const price = Number(item.unit_price) || 0;26 return sum + qty * price;27 }, 0);2829 const taxAmount = subtotal * (Number(taxRate) || 0) / 100;30 const total = subtotal + taxAmount;3132 return (33 <div className={`space-y-2 text-sm ${className}`}>34 <div className="flex justify-between">35 <span className="text-muted-foreground">Subtotal</span>36 <span>{formatCurrency(subtotal)}</span>37 </div>38 {taxRate > 0 && (39 <div className="flex justify-between">40 <span className="text-muted-foreground">Tax ({taxRate}%)</span>41 <span>{formatCurrency(taxAmount)}</span>42 </div>43 )}44 <Separator />45 <div className="flex justify-between font-semibold text-base">46 <span>Total</span>47 <span>{formatCurrency(total)}</span>48 </div>49 </div>50 );51}Customization ideas
Add recurring invoice automation
Add a recurring_schedule column to invoices (none, monthly, quarterly, annually). Create a Supabase scheduled function with pg_cron that runs daily, finds invoices due for recurrence, and auto-creates new draft invoices for the next period.
Integrate Stripe for online payment
Add a 'Pay Online' button to the emailed invoice that links to a Stripe Checkout session. The Edge Function creates the session with the invoice amount. On successful payment, a Stripe webhook updates the invoice status to 'paid'.
Add a client-facing invoice view page
Create a public URL (/invoices/public/{token}) where clients can view their invoice in the browser without logging in. Use a one-time token stored in the invoices table for security. Include a Print button for the client.
Build invoice templates
Add an invoice_templates table with pre-defined line item sets for common service packages. Let users create new invoices from a template that pre-populates the line items, saving time on repeat billing.
Add multi-currency support
Add a currency column to both clients and invoices (default USD). Show the currency symbol next to amounts in the form and preview. Store exchange rates via an Edge Function calling a free currency API.
Build an accounts receivable report
Add a /reports/accounts-receivable page with a Recharts bar chart showing monthly invoice totals vs collected amounts, an aging report (0-30, 31-60, 61-90, 90+ days overdue), and a client payment history summary.
Common pitfalls
Pitfall: Calculating totals in JavaScript instead of relying on the database trigger
How to avoid: After submitting line items, refetch the invoice from Supabase to get the trigger-updated totals. Use the database as the single source of truth for financial amounts.
Pitfall: Forgetting to cascade delete line_items when deleting an invoice
How to avoid: The schema above includes ON DELETE CASCADE on line_items.invoice_id. If you're adding to an existing table, run: ALTER TABLE line_items ADD CONSTRAINT fk_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
Pitfall: Generating sequential invoice numbers using JavaScript count
How to avoid: Use a PostgreSQL sequence: CREATE SEQUENCE invoice_number_seq; then use nextval('invoice_number_seq') in the insert. Or use a trigger that generates the number on insert.
Pitfall: Storing the sent invoice PDF in a public Storage bucket
How to avoid: Use a private Storage bucket for invoice PDFs. Generate signed URLs that expire in 1 hour when users click Download. Never store the invoice in a public bucket.
Best practices
- Use a database trigger to maintain invoice totals instead of calculating in the application layer
- Generate sequential invoice numbers using a PostgreSQL sequence for guaranteed uniqueness
- Store PDF files in a private Supabase Storage bucket and generate short-lived signed URLs for downloads
- Mark overdue invoices on page load rather than in a background job — it's simpler and accurate enough for most use cases
- Use DECIMAL(12,2) for all monetary values — never use FLOAT for money
- Display all amounts using Intl.NumberFormat for correct currency formatting based on locale
- Add a notes field to invoices for payment terms, bank details, or custom messages
- Send invoice emails from a domain you control (not a generic Resend email) to avoid spam filters
AI prompts to try
Copy these prompts to build this project faster.
I'm building an invoicing system in React with react-hook-form. I have a line items form using useFieldArray where each row has description, quantity, and unit_price fields. I need to calculate and display the subtotal, tax amount (based on a tax rate percentage input), and total in real time as the user types. Write a React component that takes the form's watch() output and renders these calculated totals, handling NaN and empty string inputs gracefully.
Add a print-friendly CSS layout to the invoice preview component so clicking 'Print Invoice' triggers the browser print dialog and shows only the invoice document without the form sidebar, navigation, or action buttons. Use a @media print CSS class that hides all non-invoice elements.
In Lovable, create a Supabase Edge Function called send-invoice-email that accepts an invoice_id, fetches the invoice with client details and line items from Supabase using the service role key, builds an HTML email containing the invoice details, and sends it to the client's email address using the Resend API. The Resend API key should be read from Deno.env.get('RESEND_API_KEY'). After sending, update the invoice status to 'sent' and set sent_at to the current timestamp.
Frequently asked questions
How do I make invoice numbers sequential and unique?
Use a PostgreSQL sequence: CREATE SEQUENCE invoice_seq; then reference it in your insert with to_char(nextval('invoice_seq'), 'FM0000') to get zero-padded numbers like 0001. Add a trigger that sets invoice_number = 'INV-' || to_char(now(), 'YYYY') || '-' || to_char(nextval('invoice_seq'), 'FM0000') automatically on INSERT.
Can clients pay invoices online through this tool?
Not by default, but you can extend it. Add Stripe Checkout integration: an Edge Function creates a Checkout session for the invoice amount, and the client is redirected to Stripe's payment page. After payment, a Stripe webhook updates the invoice status to 'paid'. This extension typically takes 1-2 hours to add.
How do I deploy this so clients can access their invoice emails?
Publish the Lovable project with the Publish button. For professional client communication, connect a custom domain in Lovable's publish settings. Update the email template in the Edge Function to link to your custom domain for any client-facing invoice view pages.
What PDF library works in Supabase Edge Functions?
Supabase Edge Functions run on Deno, which has limited library support. The most reliable approach for basic invoices is generating clean HTML and returning it as a downloadable file — modern browsers render it perfectly for printing. For true PDF generation, use a third-party API like PDFMonkey, PDFShift, or DocRaptor that accepts HTML and returns a PDF file.
How do I handle multiple currencies for international clients?
Add a currency TEXT column (e.g., 'USD', 'EUR', 'GBP') to both the clients and invoices tables. In the form, add a currency selector. Use the Intl.NumberFormat API in JavaScript to format amounts with the correct currency symbol. Store amounts in the invoice's chosen currency — don't convert them.
How does the overdue detection work?
On the invoices list page load, a Supabase update query runs: UPDATE invoices SET status = 'overdue' WHERE status = 'sent' AND due_date < today AND user_id = current_user. This is a simple approach that catches overdue invoices whenever you visit the page. For more proactive detection (running at midnight daily), you'd use a Supabase scheduled Edge Function or pg_cron.
Can I add my company logo to the invoice PDF?
Yes — store your logo in Supabase Storage (public bucket), get the public URL, and embed it as an img tag in the HTML template inside the Edge Function. For the live preview in Lovable, show the same img tag using the same public URL.
Can RapidDev help add Stripe payment links to the invoice emails?
Yes — connecting Stripe Checkout to invoice emails is a common extension to Lovable-built invoicing tools. RapidDev can help design the webhook flow to automatically mark invoices as paid when Stripe confirms payment.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation