How to Show Aging Buckets (0-30, 31-60, 61-90 Days) in Oracle APEX

Group open balances by days past due with one SQL macro, a Classic Report with Compute Sum, a stacked bar chart and an As Of Date field.

An aging report groups open balances by how long they are past due: not yet due, 0-30 days, 31-60 days, 61-90 days and over 90 days. Finance teams use it every week to see which amounts need a reminder and which are turning into bad debt.

This guide builds an aging page in Oracle APEX with one SQL macro that puts every open invoice into its bucket. A Classic Report shows the buckets per client with column totals, a stacked bar chart shows the same amounts, and an As Of Date item recalculates everything for any past date.

The example uses invoices and payments of a few clients. The same steps work for any table with a due date and an open amount, such as rentals, loans or support tickets.

Step 1: Create the Tables

Three tables hold the data:

  • AR_CLIENTS has one row per client with a unique name.
  • AR_INVOICES has one row per invoice: a unique invoice number, the client, the invoice date, the due date and the amount.
  • AR_PAYMENTS has one row per payment with its date and amount. An invoice can have several payments.

The sample data uses dates relative to today, so the buckets look the same whenever you run it. Create the tables with 6 clients, 18 invoices and 4 payments:

Example:

set pages 100 lines 200 feedback on
alter session set nls_date_format = 'DD-MON-YYYY';
create table ar_clients (
  client_id    number generated by default on null as identity,
  client_name  varchar2(100) not null,
  constraint ar_clients_pk primary key (client_id),
  constraint ar_clients_name_uk unique (client_name)
);

create table ar_invoices (
  invoice_id    number generated by default on null as identity,
  invoice_no    varchar2(20) not null,
  client_id     number not null,
  invoice_date  date not null,
  due_date      date not null,
  amount        number(12,2) not null,
  constraint ar_invoices_pk primary key (invoice_id),
  constraint ar_invoices_no_uk unique (invoice_no),
  constraint ar_invoices_client_fk foreign key (client_id) references ar_clients (client_id),
  constraint ar_invoices_amount_ck check (amount > 0),
  constraint ar_invoices_due_ck check (due_date >= invoice_date)
);

create index ar_invoices_client_ix on ar_invoices (client_id);

create table ar_payments (
  payment_id    number generated by default on null as identity,
  invoice_id    number not null,
  payment_date  date not null,
  amount        number(12,2) not null,
  constraint ar_payments_pk primary key (payment_id),
  constraint ar_payments_invoice_fk foreign key (invoice_id) references ar_invoices (invoice_id),
  constraint ar_payments_amount_ck check (amount > 0)
);

create index ar_payments_invoice_ix on ar_payments (invoice_id);

insert into ar_clients (client_name)
select client_name
  from (values ('Northwind Traders'), ('Bluebird Studio'), ('Cedar Hardware'),
               ('Delta Logistics'), ('Evergreen Foods'), ('Fjord Marine')) v (client_name);

insert into ar_invoices (invoice_no, client_id, invoice_date, due_date, amount)
select v.invoice_no, c.client_id, trunc(sysdate) - v.days_ago, trunc(sysdate) - v.days_ago + v.terms, v.amount
  from (values ('INV-1001', 'Northwind Traders',  15, 30, 4200),
               ('INV-1002', 'Northwind Traders',  50, 30, 3100),
               ('INV-1003', 'Northwind Traders', 110, 30, 2500),
               ('INV-1004', 'Bluebird Studio',    10, 30, 1800),
               ('INV-1005', 'Bluebird Studio',    75, 30, 2600),
               ('INV-1006', 'Bluebird Studio',   140, 30,  900),
               ('INV-1007', 'Cedar Hardware',     35, 30, 5400),
               ('INV-1008', 'Cedar Hardware',     62, 30, 3300),
               ('INV-1009', 'Cedar Hardware',     95, 30, 1500),
               ('INV-1010', 'Delta Logistics',    20, 15, 2750),
               ('INV-1011', 'Delta Logistics',   125, 30, 6100),
               ('INV-1012', 'Delta Logistics',    45, 30, 1200),
               ('INV-1013', 'Evergreen Foods',     5, 30, 3900),
               ('INV-1014', 'Evergreen Foods',    80, 30, 2200),
               ('INV-1015', 'Evergreen Foods',   160, 30, 4800),
               ('INV-1016', 'Fjord Marine',       40, 30, 3500),
               ('INV-1017', 'Fjord Marine',      100, 30, 2900),
               ('INV-1018', 'Fjord Marine',        2, 30, 1600)) v (invoice_no, client_name, days_ago, terms, amount)
  join ar_clients c on c.client_name = v.client_name;

insert into ar_payments (invoice_id, payment_date, amount)
select i.invoice_id, trunc(sysdate) - v.days_ago, v.amount
  from (values ('INV-1012', 10, 1200),
               ('INV-1014', 30, 1000),
               ('INV-1015', 60, 2000),
               ('INV-1016',  3, 3500)) v (invoice_no, days_ago, amount)
  join ar_invoices i on i.invoice_no = v.invoice_no;

commit;

select i.invoice_no, c.client_name, i.invoice_date, i.due_date, i.amount,
       (select nvl(sum(p.amount), 0) from ar_payments p where p.invoice_id = i.invoice_id) as paid
  from ar_invoices i
  join ar_clients c on c.client_id = i.client_id
 order by i.invoice_no;

Output:

Session altered.

Table AR_CLIENTS created.

Table AR_INVOICES created.

Index AR_INVOICES_CLIENT_IX created.

Table AR_PAYMENTS created.

Index AR_PAYMENTS_INVOICE_IX created.

6 rows inserted.

18 rows inserted.

4 rows inserted.

Commit complete.

INVOICE_NO    CLIENT_NAME          INVOICE_DATE    DUE_DATE          AMOUNT    PAID
_____________ ____________________ _______________ ______________ _________ _______
INV-1001      Northwind Traders    05-OCT-2026     04-NOV-2026         4200       0
INV-1002      Northwind Traders    31-AUG-2026     30-SEP-2026         3100       0
INV-1003      Northwind Traders    02-JUL-2026     01-AUG-2026         2500       0
INV-1004      Bluebird Studio      10-OCT-2026     09-NOV-2026         1800       0
INV-1005      Bluebird Studio      06-AUG-2026     05-SEP-2026         2600       0
INV-1006      Bluebird Studio      02-JUN-2026     02-JUL-2026          900       0
INV-1007      Cedar Hardware       15-SEP-2026     15-OCT-2026         5400       0
INV-1008      Cedar Hardware       19-AUG-2026     18-SEP-2026         3300       0
INV-1009      Cedar Hardware       17-JUL-2026     16-AUG-2026         1500       0
INV-1010      Delta Logistics      30-SEP-2026     15-OCT-2026         2750       0
INV-1011      Delta Logistics      17-JUN-2026     17-JUL-2026         6100       0
INV-1012      Delta Logistics      05-SEP-2026     05-OCT-2026         1200    1200
INV-1013      Evergreen Foods      15-OCT-2026     14-NOV-2026         3900       0
INV-1014      Evergreen Foods      01-AUG-2026     31-AUG-2026         2200    1000
INV-1015      Evergreen Foods      13-MAY-2026     12-JUN-2026         4800    2000
INV-1016      Fjord Marine         10-SEP-2026     10-OCT-2026         3500    3500
INV-1017      Fjord Marine         12-JUL-2026     11-AUG-2026         2900       0
INV-1018      Fjord Marine         18-OCT-2026     17-NOV-2026         1600       0

18 rows selected.
  • AR_INVOICES_CLIENT_FK and AR_PAYMENTS_INVOICE_FK link the rows. Both foreign keys have an index, which the aging query uses for its joins.
  • AR_INVOICES_DUE_CK makes sure that no invoice is due before its invoice date. AR_INVOICES_AMOUNT_CK and AR_PAYMENTS_AMOUNT_CK allow only positive amounts.
  • The open balance is not stored. It is calculated from the payments, so it can be calculated for any date.

In this example, INV-1012 is fully paid, INV-1014 and INV-1015 are partly paid, and INV-1016 was paid three days ago.

Step 2: Create the Aging Function

The bucket rules are needed in three places on the page: the summary report, the chart and the invoice list. Writing them once keeps them the same everywhere.

A table SQL macro does this well. It is a function that returns the text of a query. Oracle puts that text into the calling SQL statement, so the macro is used like a view that takes a parameter, here the as-of date:

Example:

create or replace function ar_aging_items (p_as_of in date)
return varchar2 sql_macro(table)
is
begin
  return q'{
    select i.invoice_id,
           i.invoice_no,
           c.client_name,
           i.invoice_date,
           i.due_date,
           i.amount,
           i.amount - nvl(p.paid_amount, 0) as balance,
           p_as_of - i.due_date as days_overdue,
           case
             when p_as_of < i.due_date then 'Not Due'
             when p_as_of - i.due_date <= 30 then '0-30'
             when p_as_of - i.due_date <= 60 then '31-60'
             when p_as_of - i.due_date <= 90 then '61-90'
             else 'Over 90'
           end as bucket,
           case
             when p_as_of < i.due_date then 1
             when p_as_of - i.due_date <= 30 then 2
             when p_as_of - i.due_date <= 60 then 3
             when p_as_of - i.due_date <= 90 then 4
             else 5
           end as bucket_seq
      from ar_invoices i
      join ar_clients c on c.client_id = i.client_id
      left join (select invoice_id, sum(amount) as paid_amount
                   from ar_payments
                  where payment_date <= p_as_of
                  group by invoice_id) p on p.invoice_id = i.invoice_id
     where i.invoice_date <= p_as_of
       and i.amount - nvl(p.paid_amount, 0) > 0
  }';
end;
/

Output:

Function AR_AGING_ITEMS compiled

How the macro works:

  • The inline view adds up only the payments made on or before the as-of date. BALANCE is the amount minus those payments.
  • DAYS_OVERDUE is the as-of date minus the due date. A negative number means the invoice is not due yet.
  • The CASE expression gives the bucket name, and a second CASE gives BUCKET_SEQ, a number that sorts the buckets in their natural order.
  • Invoices dated after the as-of date and invoices without an open balance are left out.

An invoice due today has 0 days overdue and lands in 0-30. If your rules count it as not due, change the first condition from < to <=.

Query the macro for today to see each open invoice and its bucket:

Example:

set pages 100 lines 200 feedback on
alter session set nls_date_format = 'DD-MON-YYYY';
select invoice_no, client_name, due_date, balance, days_overdue, bucket
  from ar_aging_items(trunc(sysdate))
 order by bucket_seq, days_overdue, invoice_no;

Output:

Session altered.

INVOICE_NO    CLIENT_NAME          DUE_DATE          BALANCE    DAYS_OVERDUE BUCKET
_____________ ____________________ ______________ __________ _______________ __________
INV-1018      Fjord Marine         17-NOV-2026          1600             -28 Not Due
INV-1013      Evergreen Foods      14-NOV-2026          3900             -25 Not Due
INV-1004      Bluebird Studio      09-NOV-2026          1800             -20 Not Due
INV-1001      Northwind Traders    04-NOV-2026          4200             -15 Not Due
INV-1007      Cedar Hardware       15-OCT-2026          5400               5 0-30
INV-1010      Delta Logistics      15-OCT-2026          2750               5 0-30
INV-1002      Northwind Traders    30-SEP-2026          3100              20 0-30
INV-1008      Cedar Hardware       18-SEP-2026          3300              32 31-60
INV-1005      Bluebird Studio      05-SEP-2026          2600              45 31-60
INV-1014      Evergreen Foods      31-AUG-2026          1200              50 31-60
INV-1009      Cedar Hardware       16-AUG-2026          1500              65 61-90
INV-1017      Fjord Marine         11-AUG-2026          2900              70 61-90
INV-1003      Northwind Traders    01-AUG-2026          2500              80 61-90
INV-1011      Delta Logistics      17-JUL-2026          6100              95 Over 90
INV-1006      Bluebird Studio      02-JUL-2026           900             110 Over 90
INV-1015      Evergreen Foods      12-JUN-2026          2800             130 Over 90

16 rows selected.

Sixteen invoices are open. INV-1012 and INV-1016 are missing because they are paid. The totals per bucket are the numbers the APEX page must show:

Example:

set pages 100 lines 200 feedback on
select bucket, count(*) as invoices, sum(balance) as balance
  from ar_aging_items(trunc(sysdate))
 group by bucket, bucket_seq
 order by bucket_seq;

Output:

BUCKET        INVOICES    BALANCE
__________ ___________ __________
Not Due              4      11500
0-30                 3      11250
31-60                3       7100
61-90                3       6900
Over 90              3       9800

5 rows selected.

Step 3: Create the Classic Report Page

The summary report has one row per client and one column per bucket. Each column adds up only the balances of its bucket:

Example:

select client_name,
       sum(case bucket when 'Not Due' then balance end) as not_due,
       sum(case bucket when '0-30'    then balance end) as days_0_30,
       sum(case bucket when '31-60'   then balance end) as days_31_60,
       sum(case bucket when '61-90'   then balance end) as days_61_90,
       sum(case bucket when 'Over 90' then balance end) as days_over_90,
       sum(balance) as total_balance
  from ar_aging_items(nvl(to_date(:P840_AS_OF default null on conversion error, 'YYYY-MM-DD'), trunc(sysdate)))
 group by client_name
 order by client_name

The page item P840_AS_OF is created in Step 4. The query reads it with the format YYYY-MM-DD. DEFAULT NULL ON CONVERSION ERROR turns a value that is not a valid date into NULL, and NVL then uses today. A bad value can never break the query with an ORA- error.

In Page Designer, click Create (the plus icon) and choose Page. Select the Classic Report tile:

Create a Page wizard with the Classic Report tile marked with a red box
Choose the Classic Report page type.

On the Create Classic Report screen, enter Page Number 840 and Name Aging Report. Leave Page Mode Normal and Include Form Page off:

Create Classic Report screen with Page Number 840 and Name Aging Report marked with a red box
Page number and name.

Under Data Source, keep Local Database, set Source Type to SQL Query and paste the query above into the editor:

Data Source section with Source Type SQL Query and the aging summary query marked with a red box
The report source is the summary query.

Under Navigation, keep the defaults: a breadcrumb and a new navigation menu entry with the icon fa-table. Click Create Page:

Navigation section of the Create Classic Report wizard with the default breadcrumb and navigation menu settings
Keep the navigation defaults and create the page.

The wizard creates page 840 with the region Aging Report. In this test it turned Sortable off for every column. Keep it off: the clients are sorted by the query, and the total row must stay at the bottom.

Step 4: Add the As Of Date Item

Right-click Body in the Rendering tree and choose Create Region. Set Name to Report Date and keep Type Static Content. Drag it above the Aging Report region, or give it Sequence 10 and the report Sequence 30:

New region with Name Report Date and Type Static Content marked with a red box
A small region for the date field.

Right-click the Report Date region, choose Create Page Item and set:

  • Name: P840_AS_OF, Type: Date Picker
  • Label: As Of Date
  • Layout: Region Report Date, Column Span 3
P840_AS_OF with Name, Type Date Picker, Label As Of Date, Region Report Date and Column Span 3 marked with red boxes
The As Of Date item.

Under Appearance, set Template to Required - Floating and Format Mask to YYYY-MM-DD. Under Validation, turn on Value Required:

Appearance Template Required - Floating, Format Mask YYYY-MM-DD and Validation Value Required marked with a red box
A fixed format that the queries can read.

The format mask is the same one the queries use in TO_DATE. YYYY-MM-DD has no month names, so it also works when the application runs in another language.

Under Default, set Type to Expression, Language to PL/SQL and PL/SQL Expression to:

Example:

to_char(trunc(sysdate), 'YYYY-MM-DD')
Default Type Expression, Language PL/SQL and the PL/SQL Expression marked with a red box
The page opens with today's date.

Step 5: Format the Report Columns and Add Totals

Select the Aging Report region. Under Source, set Page Items to Submit to P840_AS_OF. Each refresh then sends the current date to the server. Under Advanced, set HTML DOM ID to aging-report:

Aging Report region with the SQL Query and Page Items to Submit P840_AS_OF marked with a red box
The report reads the as-of date.

Under Messages, set When No Data Found to No open invoices on this date.

Expand Columns under the region and set these headings:

  • CLIENT_NAME: Client
  • NOT_DUE: Not Due
  • DAYS_0_30: 0-30 Days
  • DAYS_31_60: 31-60 Days
  • DAYS_61_90: 61-90 Days
  • DAYS_OVER_90: Over 90 Days
  • TOTAL_BALANCE: Total

For each of the six number columns, set Appearance > Format Mask to FM999G999G990D00. The screenshot shows NOT_DUE:

NOT_DUE column with Heading Not Due and Format Mask FM999G999G990D00 marked with red boxes
Heading and number format of a bucket column.

Then turn on Advanced > Compute Sum for the same six columns:

NOT_DUE column with Advanced Compute Sum turned on marked with a red box
APEX adds a total row for the column.

Compute Sum is a declarative feature of the Classic Report. APEX adds a Report Total row at the bottom, so the query needs no ROLLUP and no union for the totals.

Step 6: Add the Stacked Bar Chart

Right-click Body and choose Create Region. Set Name to Balance by Aging Bucket and Type to Chart. Give it Sequence 20, so it sits between the date field and the report:

Chart region with Name Balance by Aging Bucket and Type Chart marked with a red box
A chart region for the buckets.

Under Advanced, set HTML DOM ID to aging-chart:

Chart region Advanced HTML DOM ID aging-chart marked with a red box
A fixed id for the chart region.

Expand the region, select its series under Series and rename it Buckets. Set Source > Type to SQL Query, enter the query below and set Page Items to Submit to P840_AS_OF:

Example:

select client_name,
       bucket,
       bucket_seq,
       sum(balance) as balance
  from ar_aging_items(nvl(to_date(:P840_AS_OF default null on conversion error, 'YYYY-MM-DD'), trunc(sysdate)))
 group by client_name, bucket, bucket_seq
 order by bucket_seq, client_name
Series Buckets with Source Type SQL Query, the chart query and Page Items to Submit P840_AS_OF marked with red boxes
One series query for all buckets.

The query returns one row per client and bucket. Under Column Mapping, set Series Name to BUCKET, Label to CLIENT_NAME and Value to BALANCE:

Column Mapping with Series Name BUCKET, Label CLIENT_NAME and Value BALANCE marked with a red box
Series Name splits the rows into one series per bucket.

Series Name is the key setting. APEX creates one chart series for each distinct BUCKET value, so a single SQL query draws all five buckets. Because the query sorts by BUCKET_SEQ first, the series appear in bucket order, from Not Due to Over 90.

Select the chart region again and open the Attributes tab. Keep Chart > Type Bar, set Appearance > Orientation to Horizontal, turn on Stack and set Layout > Height to 360:

Chart attributes with Type Bar, Orientation Horizontal, Stack on, Height 360 and Sort Order Label - Ascending marked with red boxes
Horizontal stacked bars.

Keep Multi-Series Chart Data > Sort Order at its default, Label - Ascending. It sorts the clients by name on the chart.

Under Legend, turn on Show and set Position to Bottom:

Legend Show on and Position Bottom marked with a red box
The legend names the buckets.

The default chart colors do not say anything about risk. Under Advanced, enter this Initialization JavaScript Function. It gives the five series colors from green to red, in the same order as the buckets:

Example:

function( options ) {
    options.styleDefaults = $.extend( {}, options.styleDefaults, {
        colors: [ "#2e7d32", "#9ccc65", "#fbc02d", "#fb8c00", "#c62828" ]
    } );
    return options;
}
Advanced Initialization JavaScript Function with the colors function marked with a red box
Green for not due, red for over 90 days.

Expand Axes and select y. Set Title to Balance, Value > Format to Decimal and Decimal Places to 0. With a horizontal chart, the y axis is the value axis at the bottom:

Axis y with Title Balance, Format Decimal and Decimal Places 0 marked with red boxes
The value axis.

Step 7: Add the Open Invoices Report

The summary shows the amounts, but users also want to see which invoices are behind them. Right-click Body, choose Create Region and set Name to Open Invoices, Type to Classic Report and Sequence to 40. Set Source > Type to SQL Query with this query and Page Items to Submit to P840_AS_OF:

Example:

select invoice_no,
       client_name,
       invoice_date,
       due_date,
       days_overdue,
       bucket,
       amount,
       balance
  from ar_aging_items(nvl(to_date(:P840_AS_OF default null on conversion error, 'YYYY-MM-DD'), trunc(sysdate)))
 order by bucket_seq, days_overdue desc, invoice_no
Open Invoices region with Name, the open items SQL Query and Page Items to Submit P840_AS_OF marked with red boxes
The invoices behind the totals.

Set HTML DOM ID to open-invoices and When No Data Found to No open invoices on this date. In the columns, set Format Mask YYYY-MM-DD for INVOICE_DATE and DUE_DATE, FM999G999G990D00 for AMOUNT and BALANCE, and turn off Sortable for all columns, as in the summary report.

Step 8: Refresh the Page When the Date Changes

Right-click P840_AS_OF and choose Create Dynamic Action. Set Name to As Of Date Changed and keep Event Change, Selection Type Item(s) and Item(s) P840_AS_OF.

Under Client-side Condition, set Type to JavaScript expression and JavaScript Expression to:

Example:

apex.item( "P840_AS_OF" ).getValidity().valid
Dynamic action As Of Date Changed with Event Change, Item P840_AS_OF and a JavaScript expression client-side condition marked with red boxes
The True actions run only for a valid date.

Why the condition? The first version of the page had no condition. Typing abc into the date field refreshed the regions, and the server used today because of the NVL fallback. The page showed today's numbers while the field said abc, which is misleading.

The date picker already knows when its value is not a date. getValidity().valid is false for abc, for 2026-13-45, for 2026-02-30 and for an empty field, so the True actions run only for real dates.

Select the True action and set Action to Execute JavaScript Code with this code. It removes an error message from an earlier invalid date. Turn off Fire on Initialization:

Example:

apex.message.clearErrors();
True action Execute JavaScript Code with the clearErrors code and Fire on Initialization off marked with red boxes
Clear old errors first.

Right-click True and choose Create Action three times. Set each to Action Refresh, Selection Type Region, with the regions Balance by Aging Bucket, Aging Report and Open Invoices. Turn off Fire on Initialization for all three, because the regions already load with the page:

Refresh action with Selection Type Region, Region Balance by Aging Bucket and Fire on Initialization off marked with red boxes
One Refresh action per region.

Right-click False, choose Create Action and set Action to Execute JavaScript Code with this code. Keep Fire When Event Result Is False and turn off Fire on Initialization:

Example:

apex.message.clearErrors();
apex.message.showErrors( [ {
    type: "error",
    location: [ "page", "inline" ],
    pageItem: "P840_AS_OF",
    message: apex.item( "P840_AS_OF" ).getValidationMessage(),
    unsafe: false
} ] );
False action Execute JavaScript Code with the showErrors code, Fire When Event Result Is False and Fire on Initialization off marked with red boxes
Show the date picker's own message.

getValidationMessage returns the message of the date picker, for example "As Of Date must be a valid date". The regions keep the numbers of the last valid date. Save the page.

Step 9: Test the Aging Report

Sign in as a normal user and open Aging Report from the navigation menu. The page opens with today's date:

Aging Report page with the As Of Date, the stacked bar chart, the aging summary with Report Total row and the Open Invoices list
The aging page for today.

The Report Total row shows 11,500.00, 11,250.00, 7,100.00, 6,900.00 and 9,800.00, exactly the bucket totals from Step 2, and a grand total of 46,550.00. The Open Invoices list has the same 16 invoices, and every bar segment in the chart matches a cell of the summary.

Now change the As Of Date to the date seven days earlier (2026-10-13 in this test). All three regions refresh without reloading the page:

Aging Report with the As Of Date changed to a week earlier marked with a red box and the recalculated chart and totals
The same report one week earlier.

The totals are now 14,150.00, 9,900.00, 5,300.00, 11,500.00 and 3,700.00. Check them in SQL and list every invoice that has a different bucket or balance between the two dates:

Example:

set pages 100 lines 200 feedback on
select bucket, count(*) as invoices, sum(balance) as balance
  from ar_aging_items(trunc(sysdate) - 7)
 group by bucket, bucket_seq
 order by bucket_seq;

select coalesce(t.invoice_no, w.invoice_no) as invoice_no,
       w.bucket  as week_ago_bucket,
       w.balance as week_ago_balance,
       t.bucket  as today_bucket,
       t.balance as today_balance
  from ar_aging_items(trunc(sysdate)) t
  full join ar_aging_items(trunc(sysdate) - 7) w on w.invoice_id = t.invoice_id
 where decode(t.bucket, w.bucket, 0, 1) = 1
    or decode(t.balance, w.balance, 0, 1) = 1
 order by 1;

Output:

BUCKET        INVOICES    BALANCE
__________ ___________ __________
Not Due              4      14150
0-30                 3       9900
31-60                3       5300
61-90                3      11500
Over 90              2       3700

5 rows selected.

INVOICE_NO    WEEK_AGO_BUCKET       WEEK_AGO_BALANCE TODAY_BUCKET       TODAY_BALANCE
_____________ __________________ ___________________ _______________ ________________
INV-1007      Not Due                           5400 0-30                        5400
INV-1008      0-30                              3300 31-60                       3300
INV-1009      31-60                             1500 61-90                       1500
INV-1010      Not Due                           2750 0-30                        2750
INV-1011      61-90                             6100 Over 90                     6100
INV-1013                                             Not Due                     3900
INV-1016      0-30                              3500
INV-1018                                             Not Due                     1600

8 rows selected.

The bucket totals match the page. The second query shows why they moved:

  • INV-1007 and INV-1010 were not due a week ago and are now in 0-30.
  • INV-1008, INV-1009 and INV-1011 each moved one bucket up as they got older.
  • INV-1016 was still open a week ago. Its payment came three days ago, so it is gone today.
  • INV-1013 and INV-1018 did not exist a week ago.

This works because the balance is calculated from payment dates. A stored balance column holds only today's value, so INV-1016 would be missing from the week-ago view.

Next, a negative test. Type abc into the As Of Date field and leave it:

As Of Date field with the value abc and the error As Of Date must be a valid date marked with a red box, and the page error notification
An invalid date shows an error instead of wrong numbers.

The error appears in the notification and below the field, and the regions keep the numbers of 2026-10-13. Setting 2026-13-45 from the browser console with apex.item("P840_AS_OF").setValue gave the same error. Entering a valid date again removed the error and refreshed the regions.

Last, the server side. In the browser console, the value abc was set without a change event and the Aging Report region was refreshed directly, which skips the dynamic action. The region showed today's totals, 46,550.00, and no ORA- error. DEFAULT NULL ON CONVERSION ERROR and NVL caught the forged value.

Things to Know

  • Keep the bucket rules in one place, like the SQL macro of this guide. A report and a chart with their own CASE expressions drift apart sooner or later.
  • Age by days past due, as here, or by days since the invoice date. Change the macro, not the pages, if your rules differ.
  • Calculate the open balance from dated payments if you need past as-of dates. A stored balance column only works for today.
  • Compute Sum gives the total row without SQL. Turn it on only for number columns.
  • A chart series with a Series Name column creates one series per value. In this test, the series followed the order of the query rows, so sort the query by the bucket number.
  • trunc(sysdate) uses the clock of the database server. Here the server ran on UTC, so the page defaulted to 2026-10-20 while it was already 21 October in India. Use a date in the user's time zone if that matters.
  • For large tables, index the due date and filter early. The macro reads all open invoices for every refresh of the three regions.

Related Guides

Conclusion

Aging buckets in Oracle APEX need one SQL macro with the bucket rules, a Classic Report with Compute Sum for the totals, a stacked bar chart with a Series Name column and an As Of Date item that refreshes all regions.

In this example, the totals on the page matched SQL for today and for a week earlier, invoices moved between buckets as expected, and invalid dates showed an error instead of wrong numbers. The same steps work for any table with a due date and an open amount.

Get more Vinish.dev tutorials on Google

See my articles more often when you search Google.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted