All Docs
FeaturesSaaS FactoryUpdated February 19, 2026

Blog: Eliminating Full Table Scans on the Invoices Table

Eliminating Full Table Scans on the Invoices Table

Release v1.0.47 · Database Performance

Revenue analytics is one of the most read-heavy parts of the SaaS Factory platform. Every time the revenue dashboard loads, the revenueAnalytics router fires 4–6 queries in parallel — pulling MRR time series, revenue movement breakdowns, ARR summaries, and top product rankings. Until now, every single one of those queries was doing a full sequential scan of the invoices table.

With v1.0.47, that changes.

What Was Happening

The invoices table had no indexes defined — not in billing-schema.ts, not in the migrations directory. That means the database had no choice but to read every row on every revenue analytics query, regardless of the filters applied.

The arrSummary procedure alone runs 4 parallel sub-queries all hitting the same unindexed table simultaneously. At low invoice volumes this isn't noticeable. As a product grows its billing history, the cost compounds linearly with every new invoice written.

This is a classic write-once, pay-forever problem: the penalty for missing an index only becomes visible over time, which is exactly why it's easy to miss and expensive to ignore.

The Fix

Three targeted composite indexes have been added to src/db/billing-schema.ts:

1. (project_id, paid_at, status) on invoices

This is the primary workhorse index. The revenueAnalytics router's time-series and aggregation queries always filter by project_id and slice by date ranges on paid_at. Including status in the composite allows the query planner to satisfy status filters (e.g. filtering to paid invoices only) directly from the index without touching the heap.

-- Example query that benefits:
SELECT date_trunc('month', paid_at), SUM(amount)
FROM invoices
WHERE project_id = $1
  AND paid_at >= $2
  AND status = 'paid'
GROUP BY 1;

2. (customer_id, paid_at) on invoices

Revenue movement queries (new MRR, expansion, contraction, churn) join on customer_id to track per-customer revenue changes over time. The leading customer_id column means a join probe on any given customer immediately narrows to that customer's invoice history, with paid_at available for date ordering and range filtering without an additional sort step.

3. (product_id) on invoice_line_items

The topProducts query aggregates revenue by product across invoice_line_items. A single-column index on product_id is sufficient here — line items are typically queried by product for aggregation, and the selectivity of product_id alone is high enough to make this index effective.

Performance Characteristics

MetricBeforeAfter
Query complexityO(n) — full sequential scanO(log n) — index-range scan
Estimated speed-up10–100× as invoice volume grows
Write overheadNoneMarginally increased insert/update latency

The write-time cost is real but expected to be negligible. Revenue analytics queries are read-heavy and latency-sensitive (they block dashboard renders). Inserts to invoices happen at billing event time and are already asynchronous. The trade-off strongly favours indexing.

Why Composite Indexes

Single-column indexes on project_id or paid_at alone would help, but composite indexes let the query planner satisfy multiple filter and sort conditions from a single index traversal. Ordering the columns by selectivity and query pattern — most-filtered column first — means the planner can use the same index for a wide range of query shapes without needing separate indexes for each.

Impact

Every product built on the SaaS Factory platform that uses revenue analytics benefits immediately from this change. The fix is a schema-level migration with no application code changes required — the improvement is fully transparent to the revenueAnalytics router and all existing dashboard queries.