Series 5 — Part 5 of 7

Invoice generation in a legal SaaS aggregates approved billing entries, applies discounts and retainer deductions, and produces a PDF that can be delivered to the client. This article covers the billing entry to invoice pipeline, discount types, recurring retainers, and PDF generation.

Invoice Schema

CREATE TABLE invoices (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  team_id         INT UNSIGNED NOT NULL,
  matter_id       BIGINT UNSIGNED NOT NULL,
  invoice_number  VARCHAR(50) NOT NULL,
  issued_on       DATE NOT NULL,
  due_on          DATE NOT NULL,
  currency        CHAR(3) NOT NULL DEFAULT 'INR',
  subtotal        DECIMAL(12,2) NOT NULL DEFAULT 0,
  discount_type   ENUM('none','percentage','fixed','write_off') NOT NULL DEFAULT 'none',
  discount_value  DECIMAL(10,2) NOT NULL DEFAULT 0,
  discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
  retainer_applied DECIMAL(12,2) NOT NULL DEFAULT 0,
  total           DECIMAL(12,2) NOT NULL DEFAULT 0,
  status          ENUM('draft','sent','paid','overdue','cancelled') NOT NULL DEFAULT 'draft',
  paid_on         DATE DEFAULT NULL,
  notes           TEXT DEFAULT NULL,
  UNIQUE KEY uq_team_invoice (team_id, invoice_number)
);

Generating an Invoice from Billing Entries

class InvoiceService
{
    public function generateFromApprovedEntries(int $matterId, int $teamId, PDO $pdo): int
    {
        $entries = $pdo->prepare(
            'SELECT be.*, wl.description as work_description
             FROM billing_entries be
             LEFT JOIN work_logs wl ON be.work_log_id = wl.id
             WHERE be.matter_id = ? AND be.team_id = ? AND be.invoice_id IS NULL
               AND be.entry_type != \'retainer\''
        );
        $entries->execute([$matterId, $teamId]);
        $rows = $entries->fetchAll();

        if (empty($rows)) {
            throw new \RuntimeException('No uninvoiced billing entries found.');
        }

        $subtotal = array_sum(array_map(function ($r) {
            return $r['entry_type'] === 'time'
                ? ($r['hours'] * $r['rate_per_hour'])
                : ($r['fixed_amount'] ?? 0);
        }, $rows));

        $invoiceId = $this->createInvoiceRecord($matterId, $teamId, $subtotal, $pdo);

        // Link entries to invoice
        $pdo->prepare('UPDATE billing_entries SET invoice_id = ? WHERE id IN (' .
            implode(',', array_column($rows, 'id')) . ')')->execute([$invoiceId]);

        return $invoiceId;
    }
}

Retainer Deductions

A retainer is pre-paid legal fees. When an invoice is generated for a matter with an active retainer balance, the retainer is deducted first and only the remainder is billed. Track the retainer balance in a separate table and deduct atomically in a transaction.

What to Watch For

  • Rounding in aggregations — Summing DECIMAL in PHP loses precision if you cast to float. Always aggregate in SQL (SUM(hours * rate_per_hour)) and keep amounts as strings until rendering.
  • Invoice number uniqueness — Generate invoice numbers with a per-team sequence, not a global auto-increment. ACME-2026-001 must be unique within a team, not globally.
  • Write-off accounting — A write-off reduces the billed amount but the original billable amount must remain on the record. Never delete or modify the original billing entries.