Friday, 11 September 2026

You can't see your stale documents, and that's why they're stale

Open the sales order list in Business Central and filter by date. Now do the same for quotes. Now for unposted invoices. Three lists, three filters, no way to see them together, and no way to answer the only question that matters in a Monday pipeline meeting: what is stale, and whose is it?

So the documents pile up. Quotes nobody chased. Orders shipped three weeks ago and never invoiced. Invoices sitting unposted since last month because the person who raised them went on leave. Every partner I've worked with has fielded this request, and the usual answer is a manual Excel pull that someone does once, shows in a meeting, and never does again.

I built Unposted Document Aging to replace that Excel pull with a page.

One list, aged into bands

The extension adds a single read-only list that pulls sales quotes, orders and unposted invoices and optionally purchase documents into one view, with the age of each document and the person who owns it.

The important part isn't the list. It's the bands.

A flat list of 400 stale documents gets opened once. An order created yesterday with nothing shipped is not a problem; an order fully shipped three weeks ago and never invoiced is money sitting on the floor. Those two things should not look the same on screen. So every row lands in one of six bands, sorted so the money is at the top:



Band

Rule

Shipped Not Invoiced

Order completely shipped, or with any line showing a shipped quantity

Quote Expired

Quote whose Quote Valid To Date has passed

Invoice Unposted

Unposted invoice older than the invoice threshold

Order Stale

Order past the order threshold with nothing shipped

Quote Stale

Quote past the quote threshold, not yet expired

Other

Fallback

Shipped Not Invoiced and Quote Expired are highlighted in red. Thresholds default to 30 days for quotes, 14 for orders and 7 for unposted invoices, and are configurable per company.


The argument you're going to have about dates

Here's the design decision that determines whether anyone trusts the page: which date defines "old"?

Document Date is what people mean when they say a quote is old. It is also backdated regularly, which is precisely why some people won't trust a report built on it.

Created uses the record's SystemCreatedAt and tells you when the document genuinely appeared in the system. Harder to argue with, less intuitive.

I made it a setup switch and defaulted to Document Date, because that's the one people will argue about in the meeting and they should be able to flip it and see both.

No stored data

The list is a temporary buffer built on demand by a codeunit, with the page bound to it using SourceTableTemporary. Nothing is stored. There's no job queue keeping a table warm, nothing to go stale, no upgrade code, and the numbers always reflect the live document tables. Hit Refresh and it rebuilds.

A query object can't union across Sales Header and Purchase Header, so the buffer was the only sensible route anyway but the absence of stored state turned out to be the best property of the design.

Performance was the thing I worried about most, since building a buffer from scratch on every page open is exactly how you write a slow app. What keeps it honest:

  • Every threshold is turned into a cutoff date in AL and applied as a SetFilter on the header, so SQL does the filtering. There is no loop over open documents testing ages in code.
  • One pass per document type, so each gets its own cutoff rather than a lowest-common-denominator filter.
  • SetLoadFields on both header loops.
  • Amount Including VAT is a FlowField, so CalcFields runs only on rows that already made it into the buffer never before filtering. There's a Calculate Amounts toggle to turn it off entirely on very large databases.

Amounts are converted to LCY so a mixed-currency list totals meaningfully, with the currency code kept for display. Choosing a document number opens the actual document, whatever type it is, via a stored RecordId.

The part that actually changes behaviour

A page helps the person who opens it. A digest helps everyone else.

The extension can email a daily digest, and the feature worth having is that it's per salesperson. Rows are grouped by salesperson code, each person's address is resolved from their Salesperson/Purchaser record, and each person gets only their own rows. A digest that goes to everyone gets filtered into a folder and ignored. One that says "you have 6 quotes over 30 days old" gets read.

Managers and shared mailboxes can be added as separate recipients, optionally filtered to a subset of salespeople. It sends through the modern email module with its own Email Scenario, so an admin can point it at a dedicated account on the Email Accounts page. One bad address doesn't abort the run, failures are collected and shown on the setup page. There's a button that creates the recurring job queue entry so nobody has to know a codeunit ID.


It's a per-tenant extension targeting BC 26 and later, and it compiles clean under CodeCop and UICop.

Source: https://github.com/pmohanakrishna/Unposted-Document-Aging

If you install it and the bands don't match how your team actually triages work, that's the most useful thing you could tell me.

No comments:

Post a Comment