Accounts Receivable and Aging Analysis

Accounts Receivable and Aging Analysis

Accounts Receivable (AR) aging is the most important report in cash flow management. It answers: "Of the money owed to us, how old is each unpaid invoice?" An invoice that's 1 day overdue is very different from one that's 90+ days overdue — the older it is, the lower the probability of collection, and the more urgent the intervention.

Every accounting system (QuickBooks, Xero, SAP) generates an aging report. This lesson shows you how to build one from raw data in SQL.

What "Aging" Means

Aging groups unpaid invoices by how many days past their due date they are:

Bucket Meaning
Current Not yet due
1-30 days Recently overdue — send a reminder
31-60 days Moderately overdue — escalate to account manager
61-90 days Seriously overdue — consider collections
90+ days High risk — may require legal action or write-off

The older the bucket, the lower the expected collection rate. Finance teams use aging reports daily to prioritise collection efforts.

Computing Days Outstanding

The first step is calculating how many days each unpaid invoice has been outstanding. CURRENT_DATE - due_date gives the number of days since the due date — positive means overdue, negative means not yet due.

We use GREATEST(..., 0) to treat future-due invoices as 0 days overdue (they're current, not actually overdue). The LEFT JOIN payments ensures we include invoices with no payments at all.

WITH invoice_outstanding AS (
    SELECT
        i.invoice_id,
        i.client_id,
        i.invoice_no,
        i.issue_date,
        i.due_date,
        (SELECT SUM(ii.quantity * ii.unit_price) FROM invoice_items ii WHERE ii.invoice_id = i.invoice_id),
        COALESCE(SUM(p.amount), 0) AS amount,
        (SELECT SUM(ii.quantity * ii.unit_price) FROM invoice_items ii WHERE ii.invoice_id = i.invoice_id) - COALESCE(SUM(p.amount), 0) AS outstanding,
        GREATEST(CURRENT_DATE - i.due_date, 0) AS days_overdue
    FROM invoices i
    LEFT JOIN payments p ON p.invoice_id = i.invoice_id
    WHERE i.status NOT IN ('paid', 'cancelled')
    GROUP BY i.invoice_id, i.client_id, i.invoice_no, i.issue_date, i.due_date, (SELECT SUM(ii.quantity * ii.unit_price) FROM invoice_items ii WHERE ii.invoice_id = i.invoice_id)
    HAVING (SELECT SUM(ii.quantity * ii.unit_price) FROM invoice_items ii WHERE ii.invoice_id = i.invoice_id) - COALESCE(SUM(p.amount), 0) > 0
)
SELECT
    invoice_no, issue_date, due_date,
    outstanding, days_overdue,
    CASE
        WHEN days_overdue = 0 THEN 'Current'
        WHEN days_overdue <= 30 THEN '1-30 days'
        WHEN days_overdue <= 60 THEN '31-60 days'
        WHEN days_overdue <= 90 THEN '61-90 days'
        ELSE '90+ days'
    END AS aging_bucket
FROM invoice_outstanding
ORDER BY days_overdue DESC;

What This Returns

Each row is one unpaid (or partially paid) invoice with its outstanding balance and aging bucket. The HAVING clause filters out invoices where payments have fully covered the invoice — even if status wasn't updated. This catches partially paid invoices correctly regardless of the status field.

Purchase this course to unlock the full lesson.

Sign up