# How to Build a Client Invoicing Tool with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
-- Run in Supabase SQL Editor

CREATE TYPE invoice_status AS ENUM ('draft', 'sent', 'paid', 'overdue', 'cancelled');

CREATE TABLE clients (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users NOT NULL,
  company_name TEXT NOT NULL,
  contact_name TEXT,
  email TEXT NOT NULL,
  phone TEXT,
  address TEXT,
  city TEXT,
  country TEXT,
  tax_number TEXT,
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE invoices (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users NOT NULL,
  client_id UUID REFERENCES clients(id) NOT NULL,
  invoice_number TEXT NOT NULL,
  status invoice_status DEFAULT 'draft',
  issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
  due_date DATE NOT NULL,
  subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
  tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
  tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
  total DECIMAL(12,2) NOT NULL DEFAULT 0,
  notes TEXT,
  pdf_storage_path TEXT,
  sent_at TIMESTAMPTZ,
  paid_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE line_items (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  invoice_id UUID REFERENCES invoices(id) ON DELETE CASCADE NOT NULL,
  description TEXT NOT NULL,
  quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
  unit_price DECIMAL(12,2) NOT NULL,
  amount DECIMAL(12,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
  position INTEGER NOT NULL DEFAULT 0
);

-- Trigger to update invoice totals when line_items change
CREATE OR REPLACE FUNCTION update_invoice_totals()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
  v_subtotal DECIMAL(12,2);
  v_tax_rate DECIMAL(5,2);
BEGIN
  SELECT COALESCE(SUM(quantity * unit_price), 0) INTO v_subtotal
  FROM line_items WHERE invoice_id = COALESCE(NEW.invoice_id, OLD.invoice_id);
  
  SELECT tax_rate INTO v_tax_rate FROM invoices
  WHERE id = COALESCE(NEW.invoice_id, OLD.invoice_id);
  
  UPDATE invoices SET
    subtotal = v_subtotal,
    tax_amount = ROUND(v_subtotal * v_tax_rate / 100, 2),
    total = v_subtotal + ROUND(v_subtotal * v_tax_rate / 100, 2),
    updated_at = now()
  WHERE id = COALESCE(NEW.invoice_id, OLD.invoice_id);
  
  RETURN NEW;
END;
$$;

CREATE TRIGGER line_items_changed
AFTER INSERT OR UPDATE OR DELETE ON line_items
FOR EACH ROW EXECUTE FUNCTION update_invoice_totals();

-- RLS
ALTER TABLE clients ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE line_items ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own clients" ON clients FOR ALL USING (user_id = auth.uid());
CREATE POLICY "Users manage own invoices" ON invoices FOR ALL USING (user_id = auth.uid());
CREATE POLICY "Users manage own line items" ON line_items FOR ALL USING (
  EXISTS (SELECT 1 FROM invoices WHERE id = invoice_id AND user_id = auth.uid())
);
```

> 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.

### 2. 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.

```
Build the invoice creation page at /invoices/new with a two-column layout:

LEFT COLUMN (form, 55% width):
1. Invoice header fields:
   - Client select (shadcn Select, fetches from clients table, shows company name + email)
   - Invoice number (auto-generated: INV-{year}-{sequential number}, editable)
   - Issue date (shadcn Calendar Popover, default today)
   - Due date (shadcn Calendar Popover, default 30 days from today)
   - Tax rate percentage (number input, default 0)

2. Line items section with useFieldArray:
   - Each row: Description (text input, flex-grow), Quantity (number, 80px), Unit Price (number, 120px), Amount (calculated, 100px, read-only), Remove button (X)
   - "Add Line Item" button adds a new row at the bottom
   - Minimum 1 line item required (show error if empty on submit)
   - Amount column = quantity * unit_price calculated in real time

3. Totals summary (right-aligned below line items):
   - Subtotal: sum of all amounts
   - Tax ({tax_rate}%): subtotal * tax_rate / 100
   - Total: subtotal + tax (bold, larger text)

4. Notes textarea (optional)
5. Submit buttons: "Save as Draft" and "Save and Preview"

Right Column (preview): see next step

Use 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.

### 3. 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.

```
Build a live invoice preview component that displays in the right column of the invoice form:

Preview layout (styled like a real invoice document, white background, subtle shadow):
1. Header row:
   - Left: Your business name (bold, large) + address placeholder text
   - Right: "INVOICE" label (large, primary color) + Invoice Number + Issue/Due dates

2. Bill To section:
   - Client company name (populated from selected client)
   - Contact name, email, address (populated from client record)

3. Line items table:
   - Column headers: Description, Qty, Unit Price, Amount
   - One row per line item from the form (live-updating as user types)
   - Alternating row background: white and gray-50

4. Totals section (right-aligned):
   - Subtotal row
   - Tax row (hidden if tax_rate = 0)
   - Bold total row with border-top

5. Notes section at bottom (if notes field has content)
6. Footer: payment terms placeholder + "Thank you for your business"

Watch 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.

### 4. 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.

```
// supabase/functions/generate-invoice-pdf/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });

  try {
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    );

    const { invoice_id } = await req.json();

    const { data: invoice } = await supabase
      .from('invoices')
      .select('*, clients(*), line_items(* order by position)')
      .eq('id', invoice_id)
      .single();

    if (!invoice) throw new Error('Invoice not found');

    const lineItemsHtml = invoice.line_items.map((item: Record<string, unknown>) => `
      <tr style="border-bottom: 1px solid #eee;">
        <td style="padding: 8px 4px;">${item.description}</td>
        <td style="padding: 8px 4px; text-align: right;">${item.quantity}</td>
        <td style="padding: 8px 4px; text-align: right;">$${Number(item.unit_price).toFixed(2)}</td>
        <td style="padding: 8px 4px; text-align: right;">$${Number(item.amount).toFixed(2)}</td>
      </tr>
    `).join('');

    const html = `<!DOCTYPE html><html><head><meta charset="UTF-8">
      <style>body{font-family:Arial,sans-serif;color:#333;padding:40px;max-width:800px;margin:0 auto;}
      .header{display:flex;justify-content:space-between;margin-bottom:32px;}
      table{width:100%;border-collapse:collapse;}
      th{text-align:left;padding:8px 4px;border-bottom:2px solid #333;font-size:12px;text-transform:uppercase;color:#666;}
      .totals{margin-top:24px;text-align:right;} .total-row{font-weight:bold;font-size:18px;border-top:2px solid #333;padding-top:8px;}
      </style></head><body>
      <div class="header">
        <div><h2>Your Business Name</h2></div>
        <div style="text-align:right;"><h1 style="color:#2563eb;margin:0;">INVOICE</h1>
          <p>Invoice #: ${invoice.invoice_number}</p>
          <p>Date: ${invoice.issue_date}</p><p>Due: ${invoice.due_date}</p></div>
      </div>
      <div><strong>Bill To:</strong><br/>
        ${invoice.clients.company_name}<br/>
        ${invoice.clients.contact_name || ''}<br/>
        ${invoice.clients.email}</div>
      <table style="margin-top:32px;">
        <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>
        <tbody>${lineItemsHtml}</tbody>
      </table>
      <div class="totals">
        <p>Subtotal: $${Number(invoice.subtotal).toFixed(2)}</p>
        ${invoice.tax_rate > 0 ? `<p>Tax (${invoice.tax_rate}%): $${Number(invoice.tax_amount).toFixed(2)}</p>` : ''}
        <p class="total-row">Total: $${Number(invoice.total).toFixed(2)}</p>
      </div>
      ${invoice.notes ? `<p style="margin-top:32px;color:#666;">${invoice.notes}</p>` : ''}
    </body></html>`;

    const pdfResponse = await fetch('https://api.pdfmonkey.io/api/v1/documents', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${Deno.env.get('PDFMONKEY_API_KEY')}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ document: { document_template_id: 'html', payload: { html } } })
    });

    if (!pdfResponse.ok) {
      const htmlBlob = new Blob([html], { type: 'text/html' });
      const storagePath = `invoices/${invoice_id}/invoice-${invoice.invoice_number}.html`;
      await supabase.storage.from('invoice-exports').upload(storagePath, htmlBlob, { upsert: true });
      const { data: { publicUrl } } = supabase.storage.from('invoice-exports').getPublicUrl(storagePath);
      await supabase.from('invoices').update({ pdf_storage_path: storagePath }).eq('id', invoice_id);
      return new Response(JSON.stringify({ url: publicUrl, format: 'html' }), {
        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      });
    }

    return new Response(JSON.stringify({ success: true }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    });
  } catch (err) {
    return new Response(JSON.stringify({ error: err.message }), {
      status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    });
  }
});
```

> 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.

### 5. 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'.

```
Build the invoices list page at /invoices:

1. Header with "New Invoice" button and stats row (4 cards):
   - Total Invoiced (sum of all invoice totals)
   - Paid (sum of paid invoices this month)
   - Outstanding (sum of sent invoices not yet paid)
   - Overdue (count of overdue invoices, red badge if > 0)

2. shadcn DataTable with columns:
   - Invoice Number (monospace, clickable)
   - Client (company name)
   - Issue Date
   - Due Date (red if overdue)
   - Status (shadcn Badge: draft=gray, sent=blue, paid=green, overdue=red, cancelled=gray)
   - Total Amount (right-aligned)
   - Actions (View, Send, Download PDF, Mark as Paid, Delete)

3. Overdue detection: on page load, run this query in Supabase:
   UPDATE invoices SET status = 'overdue' WHERE status = 'sent' AND due_date < CURRENT_DATE AND user_id = {currentUserId}
   Do this as an RPC call or direct .update() with a filter.

4. Send Invoice flow (clicking Send action):
   - Opens a Dialog confirming: "Send invoice {number} to {client email}?"
   - On confirm: call a Supabase Edge Function 'send-invoice-email' passing invoice_id
   - Edge Function sends email via Resend with HTML invoice content
   - Update invoice status to 'sent', set sent_at = now()
   - Show success toast: "Invoice sent to {email}"

5. Mark as Paid: opens a Dialog with paid date selector. Updates status='paid', paid_at=selected_date.

Add 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 example

File: `src/components/invoices/InvoiceTotals.tsx`

```typescript
import { Separator } from '@/components/ui/separator';

interface LineItem {
  quantity: number;
  unit_price: number;
}

interface InvoiceTotalsProps {
  lineItems: LineItem[];
  taxRate: number;
  className?: string;
}

function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: 2,
  }).format(amount);
}

export function InvoiceTotals({ lineItems, taxRate, className = '' }: InvoiceTotalsProps) {
  const subtotal = lineItems.reduce((sum, item) => {
    const qty = Number(item.quantity) || 0;
    const price = Number(item.unit_price) || 0;
    return sum + qty * price;
  }, 0);

  const taxAmount = subtotal * (Number(taxRate) || 0) / 100;
  const total = subtotal + taxAmount;

  return (
    <div className={`space-y-2 text-sm ${className}`}>
      <div className="flex justify-between">
        <span className="text-muted-foreground">Subtotal</span>
        <span>{formatCurrency(subtotal)}</span>
      </div>
      {taxRate > 0 && (
        <div className="flex justify-between">
          <span className="text-muted-foreground">Tax ({taxRate}%)</span>
          <span>{formatCurrency(taxAmount)}</span>
        </div>
      )}
      <Separator />
      <div className="flex justify-between font-semibold text-base">
        <span>Total</span>
        <span>{formatCurrency(total)}</span>
      </div>
    </div>
  );
}
```

## Common mistakes

- **Calculating totals in JavaScript instead of relying on the database trigger** — If the trigger updates the invoice totals but the frontend shows its own calculation, they can temporarily go out of sync — especially if the trigger has a small delay Fix: 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.
- **Forgetting to cascade delete line_items when deleting an invoice** — Without ON DELETE CASCADE, deleting an invoice leaves orphaned line_item rows in the database Fix: 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;
- **Generating sequential invoice numbers using JavaScript count** — Two users creating invoices simultaneously could get the same invoice number if both count rows at the same time before either inserts Fix: 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.
- **Storing the sent invoice PDF in a public Storage bucket** — Anyone with the URL can access the invoice, exposing client financial data Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/client-invoicing-tool
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/client-invoicing-tool
