Aged Payables (AP631000 in the stock UI, and the report variant most finance teams actually print — AP632000-style summary packs) looks like a trivial report: pull open bills, bucket them by days past due, total by vendor. The reason it grinds to a crawl on real instances is that "days past due" is not a stored column — it is computed per document, per bucket boundary, against whatever "as of" date the user typed in, and the stock report does that computation in the report layer rather than in SQL.
Why aging is expensive: it is a per-row CASE, not a filter
A normal report filter is WHERE DocDate >= @From — one predicate, indexable. Aging buckets are different: for every open AP bill you compute @AsOfDate - DueDate, then decide which of five buckets (current, 1-30, 31-60, 61-90, 90+) it falls into, then sum by vendor and bucket. If that bucketing math happens in the report's group/expression layer instead of the schema, the engine still has to fetch every open bill for every vendor before it can throw rows away — there is no bucket index to seek on because the bucket is not a column, it is a formula evaluated at render time.
=IIf(DateDiff("d", [APInvoice.DueDate], [@AsOfDate]) <= 0, [APInvoice.CuryDocBal],
IIf(DateDiff("d", [APInvoice.DueDate], [@AsOfDate]) <= 30, [APInvoice.CuryDocBal], 0))
-- repeated per bucket, per row, on every render
The fix is to push the bucket boundary math into the schema as a computed field or a projection, so the aggregate the report actually needs — sum of balance per vendor per bucket — is closer to a GROUP BY than a row-by-row scan the layout re-derives on every paint.
Filter to open documents before anything else
The single biggest win on every aged payables tuning job I have done: make sure the schema's WHERE clause excludes fully-paid bills at the SQL level, not via a visibility expression on the detail band. APInvoice.CuryDocBal <> 0 AND APInvoice.Released = True AND APInvoice.Voided = False as a schema filter turns a "every bill this vendor ever had" scan into "the maybe 5% still open" scan. I have seen this alone cut a 90-second aged payables run to four seconds on a vendor file with a decade of history, because the report was fetching thousands of closed bills only to filter them out in the group footer.
The as-of-date trap, and retainage lines
Aging "as of" a past date is not the same as aging "as of today" filtered by date — a bill paid on the 15th should still show as open if you're aging as of the 10th. That means the report cannot filter on current balance at all when a historical as-of date is requested; it has to reconstruct the balance from APAdjust as of that date, which is a join, not a stored field. Budget for that join explicitly rather than discovering it when finance asks for a prior month-end run and the numbers don't match what was printed at the time.
If the instance uses retainage (construction/project AP), a bill with a retained amount has both a current balance and a retained balance tracked separately. An aging report that sums CuryDocBal without excluding or separately bucketing the retained portion will show retainage as "overdue" the day retention terms pass, which is technically true but confuses every AP clerk who reads the report expecting only payable-now amounts.
Wrapping up
Aged Payables is slow for one structural reason — bucket math running per row in the layout instead of as a SQL-pushed computed field — and slow for one data reason: fetching closed bills you're going to throw away. Fix the WHERE clause first, then move bucket boundaries into the schema, and treat as-of-date aging as the join-heavy operation it actually is rather than a simple filter.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.