finance.aged-debt
Aged debt report: open invoices bucketed as current, 1-30, 31-60, 61-90 and 90+ days overdue as at a date.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 13 tests, run in TypeScript, Python and Rust.
What it does
The aged debtors (or aged creditors) report: what is owed, split by how long it has been overdue as at a date.
Age is counted from the **due date**, not the invoice date, because that is what "overdue" means and what credit control chases. Days overdue is the calendar days from the due date to the as-at date: an invoice due on the as-at date is 0 days overdue and still current; one due the day before is 1 day overdue.
For example
agedDebt(invoices ×8, 2026-06-30, 30, 60, 90, GBP)→ as at 2026-06-30, buckets ×5, total £1,216.00 a ledger across every bucket, each boundary on its edge, with a creditagedDebt(, 2026-06-30, 30, 60, 90, EUR)→ as at 2026-06-30, buckets ×5, total €0.00 an empty ledger still has every bucket, in the given currencyagedDebt(invoices ×1, 2024-03-31, 30, 60, 90, GBP)→ as at 2024-03-31, buckets ×5, total £10.00 leap year: due 31 December 2023 is 91 days overdue on 31 March 2024
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function agedDebt(invoices: readonly OpenInvoice[], asAt: string, bucketDays: readonly number[], currency: string): AgedDebt
| invoices | OpenInvoice[] | open items; what is still owed on each |
| asAt | date | the date the report is drawn up to |
| bucketDays | int[] | upper bounds of the overdue buckets, ascending: [30, 60, 90] gives 1-30, 31-60, 61-90 and 90+ |
| currency | string | the report's currency, so an empty ledger still has one |
| returns | AgedDebt |
The types it declares, generated into your project
/** One open item on a customer's account. */
export interface OpenInvoice {
readonly reference: string;
readonly dueDate: string;
/** what is still owed; negative for an unallocated credit */
readonly outstanding: Money;
}
/** One column of the report. */
export interface AgedDebtBucket {
/** "current", "1-30", ... "90+" */
readonly label: string;
/** fewest days overdue in the bucket; null for current */
readonly fromDays: number | null;
/** most days overdue in the bucket; null for the last */
readonly toDays: number | null;
readonly total: Money;
/** the invoices in the bucket, in input order */
readonly references: readonly string[];
}
export interface AgedDebt {
readonly asAt: string;
/** current first, then oldest last */
readonly buckets: readonly AgedDebtBucket[];
/** the sum of every bucket */
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { agedDebt } from "#fune/finance.aged-debt@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { daysBetween } from "./dates_days_between.ts"; ← from dates.days-between ^1.0.0 · built alongside by fune
import { addMoney } from "./money_add.ts"; ← from money.add ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type AgedDebt, type AgedDebtBucket, type OpenInvoice } from "./finance_aged_debt_types.ts";
/**
* Bucket open invoices by how many days past their due date they are.
*
* Age runs from the due date, not the invoice date: an invoice due today is
* current. Every bucket is returned, empty or not, so a report's columns are
* fixed.
*/
export function agedDebt(
invoices: readonly OpenInvoice[],
asAt: string,
bucketDays: readonly number[],
currency: string,
): AgedDebt {
if (bucketDays.length === 0) {
throw new RangeError("bucketDays needs at least one boundary");
}
let previous = 0;
for (const bound of bucketDays) {
if (!Number.isInteger(bound) || bound <= previous) {
throw new RangeError(`bucketDays must be whole days, ascending, from 1: received ${bound} after ${previous}`);
}
previous = bound;
}
const zero = money(0, currency);
const buckets: { label: string; fromDays: number | null; toDays: number | null; total: typeof zero; references: string[] }[] = [
{ label: "current", fromDays: null, toDays: 0, total: zero, references: [] },
];
let from = 1;
for (const bound of bucketDays) {
buckets.push({ label: `${from}-${bound}`, fromDays: from, toDays: bound, total: zero, references: [] });
from = bound + 1;
}
buckets.push({ label: `${from - 1}+`, fromDays: from, toDays: null, total: zero, references: [] });
let total = zero;
for (const invoice of invoices) {
const overdue = daysBetween(invoice.dueDate, asAt);
let index = 0;
if (overdue > 0) {
index = bucketDays.length + 1;
for (let i = 0; i < bucketDays.length; i += 1) {
if (overdue <= bucketDays[i]) {
index = i + 1;
break;
}
}
}
const bucket = buckets[index];
bucket.total = addMoney(bucket.total, invoice.outstanding);
bucket.references.push(invoice.reference);
total = addMoney(total, invoice.outstanding);
}
const result: AgedDebtBucket[] = buckets.map((b) => ({ ...b }));
return { asAt, buckets: result, total };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add finance.aged-debt
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./finance.aged-debt-1.0.0-typescript.fune, or fetch it from a terminal with fune pull finance.aged-debt@1.0.0:typescript.
The whole function, every language, is one file too: finance.aged-debt-1.0.0.fune, 22,899 bytes, sha256 c77e4bbac85a53d83b8987eb63d884d81d70c48171fdcd18c47f2fde788d57d0. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before finance.aged-debt
after — your function gets the result and the arguments, and returns the final result.
// fune: after finance.aged-debt
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.days-between in finance.aged-debt
// fune: replace money.add in finance.aged-debt
// fune: replace money.amount in finance.aged-debt
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show finance.aged-debt --steps.
// fune: step finance.aged-debt after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a ledger across every bucket, each boundary on its edge, with a credit | invoices ×8, 2026-06-30, 30, 60, 90, GBP | → | as at 2026-06-30, buckets ×5, total £1,216.00 |
| an empty ledger still has every bucket, in the given currency | , 2026-06-30, 30, 60, 90, EUR | → | as at 2026-06-30, buckets ×5, total €0.00 |
| leap year: due 31 December 2023 is 91 days overdue on 31 March 2024 | invoices ×1, 2024-03-31, 30, 60, 90, GBP | → | as at 2024-03-31, buckets ×5, total £10.00 |
| the same invoice a non-leap year later is 90 days overdue, still 61-90 | invoices ×1, 2026-03-31, 30, 60, 90, GBP | → | as at 2026-03-31, buckets ×5, total £10.00 |
| weekly buckets | invoices ×4, 2026-06-30, 7, 14, GBP | → | as at 2026-06-30, buckets ×4, total £10.00 |
| a single boundary | invoices ×2, 2026-06-30, 30, GBP | → | as at 2026-06-30, buckets ×3, total £1.50 |
| invoices due in the future are current | invoices ×1, 2026-06-30, 30, 60, 90, GBP | → | as at 2026-06-30, buckets ×5, total £9.99 |
| no boundaries is refused | , 2026-06-30, , GBP | → | error: bucketDays needs at least one boundary |
| a zero boundary is refused | , 2026-06-30, 0, 30, GBP | → | error: bucketDays must be whole days, ascending, from 1 |
| repeated boundaries are refused | , 2026-06-30, 30, 30, GBP | → | error: bucketDays must be whole days, ascending, from 1 |
Show the other 3 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| descending boundaries are refused | , 2026-06-30, 60, 30, GBP | → | error: bucketDays must be whole days, ascending, from 1 |
| an invoice in another currency is refused | invoices ×1, 2026-06-30, 30, 60, 90, GBP | → | error: currency mismatch |
| an impossible due date is refused | invoices ×1, 2026-06-30, 30, 60, 90, GBP | → | error: not a real calendar date |
More from the author
The buckets are an argument. `[30, 60, 90]` gives the usual report:
| label | days overdue | |---|---| | current | 0 or fewer (not yet due, or due today) | | 1-30 | 1 to 30 | | 31-60 | 31 to 60 | | 61-90 | 61 to 90 | | 90+ | 91 or more |
`[7, 14]` gives current, 1-7, 8-14 and 14+. The bounds must be whole days, ascending, the first at least 1. The last label reads "90+" as reports print it, and `fromDays` (91) says exactly where it starts.
Every bucket appears, even when empty, so the columns of a report never move. Each lists the references it holds in input order, so a total can always be traced to its invoices. An unallocated credit (a negative outstanding amount) reduces the bucket it falls in, as it does on a statement; allocating credits to invoices first is the caller's choice.
Calendar days, not 30-day months: an invoice due on 31 December 2023 is 91 days overdue on 31 March 2024 (a leap year) but 90 days overdue on 31 March 2026, so it is "90+" in one report and "61-90" in the other.
Files
| Path | Bytes |
|---|---|
| README.md | 1,457 |
| impl/python.py | 2,252 |
| impl/rust.rs | 4,122 |
| impl/typescript.ts | 2,211 |
| vectors.json | 7,733 |