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
DECIMALin 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-001must 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.