← Back to articles

How I Automated My Freelance Invoicing with n8n, Supabase, and a Custom Dashboard

I was spending 3+ hours every month on invoices. Here's the system I built to generate, send, and track invoices automatically — with real configs and code.

How I Automated My Freelance Invoicing with n8n, Supabase, and a Custom Dashboard

Last year I hit a breaking point. I was juggling five active clients, each with different billing cycles, hourly rates, and payment terms. Every month I'd spend half a Sunday morning pulling time entries from Toggl, formatting them in a Google Doc template, converting to PDF, emailing each client, and then manually logging who paid and who didn't.

It was stupid. I build automation systems for clients — and here I was doing manual data entry for my own business.

So I built a system. The whole thing runs on n8n for orchestration, Supabase as the database and API layer, and a tiny Next.js dashboard for oversight. Total monthly cost: under $5.

Here's exactly how it works.

The Problem (and Why Spreadsheets Weren't Enough)

Before this, I tried the obvious: a Google Sheet with formulas. It sort of worked until it didn't. The moment I needed to track partial payments, send automated reminders, or generate PDFs with custom line items, the spreadsheet became a liability.

What I actually needed was a pipeline:

  1. Pull tracked hours from my time tracker
  2. Calculate totals per client with the correct rate
  3. Generate a PDF invoice with proper formatting
  4. Email it to the client
  5. Log the invoice in a database
  6. Send myself a reminder if payment is overdue after 14 days

All of this should happen on the 1st of each month. No input from me.

Setting Up the Database in Supabase

I picked Supabase because I needed a Postgres database with a built-in REST API and auth — without managing infrastructure. The free tier gives you 500MB of database storage, which is absurd overkill for invoicing.

Here's my schema (simplified):

CREATE TABLE clients (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  hourly_rate NUMERIC(10,2) NOT NULL,
  currency TEXT DEFAULT 'EUR',
  payment_terms_days INT DEFAULT 14,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE invoices (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  client_id UUID REFERENCES clients(id),
  invoice_number TEXT NOT NULL UNIQUE,
  issued_date DATE NOT NULL,
  due_date DATE NOT NULL,
  total_amount NUMERIC(10,2) NOT NULL,
  currency TEXT DEFAULT 'EUR',
  status TEXT DEFAULT 'sent' CHECK (status IN ('draft','sent','paid','overdue')),
  pdf_url TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE line_items (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  invoice_id UUID REFERENCES invoices(id),
  description TEXT NOT NULL,
  hours NUMERIC(6,2),
  rate NUMERIC(10,2),
  amount NUMERIC(10,2) NOT NULL
);

Three tables. Nothing clever. The invoice_number is generated in the n8n workflow as INV-{YEAR}-{MONTH}-{CLIENT_SHORT} — something like INV-2026-03-ACME.

I also added a Supabase Edge Function to serve invoices as downloadable PDFs, but that came later.

The n8n Workflow: From Time Entries to Sent Invoices

n8n is where the actual automation lives. If you haven't used it: think Zapier, but self-hosted, with a visual editor, and you can write JavaScript in any node. I run it on a $6/month Railway instance (though DigitalOcean works great too at the $6 droplet tier).

Here's the high-level flow:

Cron Trigger (1st of month, 9:00 AM)
  → HTTP Request: Fetch time entries from Toggl API
  → Function: Group entries by client, calculate totals
  → Loop: For each client
    → Function: Generate invoice number & line items
    → HTTP Request: POST to Supabase (create invoice + line items)
    → HTTP Request: Generate PDF via html-to-pdf service
    → HTTP Request: Upload PDF to Supabase Storage
    → Send Email: Attach PDF, include payment details
  → Slack Notification: "3 invoices sent for March 2026"

The critical piece is the grouping function. Here's the actual JavaScript node code:

const entries = $input.all();
const clientMap = {};

for (const entry of entries) {
  const clientName = entry.json.project; // Toggl project = client
  if (!clientMap[clientName]) {
    clientMap[clientName] = {
      client: clientName,
      entries: [],
      totalHours: 0
    };
  }
  const hours = entry.json.duration / 3600;
  clientMap[clientName].entries.push({
    description: entry.json.description,
    date: entry.json.start.split('T')[0],
    hours: Math.round(hours * 100) / 100
  });
  clientMap[clientName].totalHours += hours;
}

return Object.values(clientMap).map(client => ({ json: client }));

This groups all Toggl entries by project name and calculates total hours. Each output item becomes one invoice.

Generating PDFs Without Paid Services

I didn't want to pay for a PDF API. Instead, I wrote an HTML template and convert it server-side. The template lives as a string in the n8n Function node:

const html = `
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: 'Helvetica Neue', sans-serif; padding: 40px; color: #1a1a1a; }
    .header { display: flex; justify-content: space-between; margin-bottom: 40px; }
    .invoice-number { font-size: 24px; font-weight: 700; }
    table { width: 100%; border-collapse: collapse; margin: 20px 0; }
    th { text-align: left; border-bottom: 2px solid #1a1a1a; padding: 8px 0; }
    td { padding: 8px 0; border-bottom: 1px solid #eee; }
    .total { font-size: 20px; font-weight: 700; text-align: right; margin-top: 20px; }
    .footer { margin-top: 60px; font-size: 12px; color: #666; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="invoice-number">${invoiceNumber}</div>
      <div>Issued: ${issuedDate}</div>
      <div>Due: ${dueDate}</div>
    </div>
    <div style="text-align: right">
      <strong>Piotr Filipecki</strong><br>
      Your Address<br>
      VAT: XX-XXXXXXX
    </div>
  </div>
  <h3>Bill to: ${clientName}</h3>
  <table>
    <tr><th>Description</th><th>Hours</th><th>Rate</th><th>Amount</th></tr>
    ${lineItemsHtml}
  </table>
  <div class="total">Total: ${currency} ${totalAmount}</div>
  <div class="footer">
    Payment terms: ${paymentTerms} days. Bank transfer details below.
  </div>
</body>
</html>`;

For the HTML-to-PDF conversion, I use a self-hosted Gotenberg instance (also on Railway, same project, costs nothing extra since it shares the plan). One POST request with the HTML body and you get a PDF back.

// n8n HTTP Request node config
{
  "method": "POST",
  "url": "https://your-gotenberg.railway.app/forms/chromium/convert/html",
  "sendBody": true,
  "bodyContentType": "multipart-form-data",
  "bodyParameters": {
    "files": {
      "value": Buffer.from(html),
      "options": { "filename": "index.html", "contentType": "text/html" }
    },
    "marginTop": "0.5",
    "marginBottom": "0.5"
  }
}

The resulting PDF gets uploaded to Supabase Storage and the public URL is saved in the invoices table.

The Payment Reminder Sub-Workflow

This is a separate n8n workflow that runs daily at 10 AM:

Cron (daily 10:00)
  → Supabase Query: SELECT * FROM invoices WHERE status = 'sent' AND due_date < NOW()
  → IF results exist:
    → Update status to 'overdue'
    → Send reminder email to client
    → Slack message to me: "Invoice INV-2026-02-ACME is 3 days overdue"

The reminder email is polite but direct. No passive-aggressive nonsense. Just:

Hi , this is a friendly reminder that invoice for was due on . Please let me know if you have any questions about the payment.

I debated adding escalation tiers (second reminder after 7 days, final notice after 21), but honestly, most clients pay within a day of the first reminder. If it gets past 21 days, that's a conversation — not an automated email.

The Dashboard: 50 Lines of Useful UI

I built a tiny Next.js page (inside my existing bytecore project, actually) that reads from Supabase and shows:

  • This month's invoices with status badges (sent / paid / overdue)
  • Total revenue this month vs last month
  • A "Mark as Paid" button per invoice

Here's the core component:

'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

export default function InvoiceDashboard() {
  const [invoices, setInvoices] = useState<any[]>([]);

  useEffect(() => {
    supabase
      .from('invoices')
      .select('*, clients(name)')
      .order('issued_date', { ascending: false })
      .then(({ data }) => setInvoices(data || []));
  }, []);

  const markPaid = async (id: string) => {
    await supabase.from('invoices').update({ status: 'paid' }).eq('id', id);
    setInvoices(prev =>
      prev.map(inv => inv.id === id ? { ...inv, status: 'paid' } : inv)
    );
  };

  return (
    <div style={{ maxWidth: 800, margin: '0 auto', padding: 20 }}>
      <h2>Invoices</h2>
      {invoices.map(inv => (
        <div key={inv.id} style={{
          display: 'flex', justifyContent: 'space-between',
          padding: '12px 0', borderBottom: '1px solid #eee'
        }}>
          <div>
            <strong>{inv.invoice_number}</strong> — {inv.clients?.name}
            <br />
            <span style={{ color: '#666' }}>
              {inv.currency} {inv.total_amount} · Due {inv.due_date}
            </span>
          </div>
          <div>
            <span style={{
              padding: '4px 8px', borderRadius: 4, fontSize: 12,
              background: inv.status === 'paid' ? '#d4edda' :
                         inv.status === 'overdue' ? '#f8d7da' : '#fff3cd'
            }}>
              {inv.status}
            </span>
            {inv.status !== 'paid' && (
              <button onClick={() => markPaid(inv.id)}
                style={{ marginLeft: 8, cursor: 'pointer' }}>
                Mark Paid
              </button>
            )}
          </div>
        </div>
      ))}
    </div>
  );
}

Nothing fancy. No charting library. No state management. Just Supabase's client reading data and a button that updates a row. It took me 30 minutes to build — including the time I spent arguing with myself about whether to add Tailwind (I didn't).

What This Actually Costs

Let me break down the monthly bill:

| Service | Plan | Cost | |---------|------|------| | Supabase | Free tier | $0 | | n8n on Railway | Starter | ~$3 | | Gotenberg on Railway | Same project | ~$2 | | Toggl | Free tier | $0 | | Total | | ~$5/mo |

Compare that to FreshBooks ($17/mo), QuickBooks ($30/mo), or even Wave (free but limited). My system does exactly what I need and nothing I don't.

The real savings are in time. I went from 3+ hours per month to about 10 minutes — just checking the dashboard and clicking "Mark Paid" when bank transfers come in.

Lessons After 8 Months of Running This

What worked perfectly: The core flow (time entries → invoice → email) hasn't broken once. Supabase is rock solid for this kind of simple CRUD. n8n's cron triggers are reliable.

What I had to fix: Toggl's API occasionally returns entries from the wrong date range if you query right at midnight. I added a 9 AM trigger offset and a 2-hour buffer on the date filter. Problem solved.

What I'd do differently: I'd skip the custom PDF template and use something like react-pdf from the start. The HTML-to-PDF approach works but the Gotenberg instance is another thing to maintain. For a v2, I'd generate the PDF inside a Supabase Edge Function.

What I didn't automate: Marking invoices as paid. My bank (like most European banks) doesn't have a usable API for personal accounts. So that's still one button click per invoice per month. I can live with that.

Should You Build This?

If you're a freelancer billing fewer than three clients with identical rates, just use a template. This is overkill for simple situations.

But if you're juggling multiple clients, variable hours, different currencies, or you just hate repetitive admin work — spending a weekend on this will save you hundreds of hours over the next few years.

The stack is dead simple. Supabase for data, n8n for workflows, and whatever frontend you already know for the dashboard. No microservices. No Kubernetes. No over-engineering.

Just the right amount of automation for a real problem.


Some links in this article are affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. I only recommend tools I actually use in production.