Functional Weave
Code in Rust

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

  • aged_debt(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 credit
  • aged_debt(, 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 currency
  • aged_debt(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.

pub fn aged_debt(invoices: &[OpenInvoice], as_at: &str, bucket_days: &[i64], currency: &str) -> AgedDebt
invoicesOpenInvoice[]open items; what is still owed on each
as_atdatethe date the report is drawn up to
bucket_daysint[]upper bounds of the overdue buckets, ascending: [30, 60, 90] gives 1-30, 31-60, 61-90 and 90+
currencystringthe report's currency, so an empty ledger still has one
returnsAgedDebt

The types it declares, generated into your project

/// One open item on a customer's account.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenInvoice {
    pub reference: String,
    pub due_date: String,
    /// what is still owed; negative for an unallocated credit
    pub outstanding: Money,
}

/// One column of the report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgedDebtBucket {
    /// "current", "1-30", ... "90+"
    pub label: String,
    /// fewest days overdue in the bucket; null for current
    pub from_days: Option<i64>,
    /// most days overdue in the bucket; null for the last
    pub to_days: Option<i64>,
    pub total: Money,
    /// the invoices in the bucket, in input order
    pub references: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgedDebt {
    pub as_at: String,
    /// current first, then oldest last
    pub buckets: Vec<AgedDebtBucket>,
    /// the sum of every bucket
    pub total: Money,
}

Your code names it in one line, in the file that uses it

fune!(finance.aged-debt@^1);  // then call aged_debt(…)
impl/rust.rs · 125 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_days_between::days_between;  ← from dates.days-between ^1.0.0 · built alongside by fune
use super::money_add::add_money;  ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value};  ← from money.amount ^1.0.0 · built alongside by fune

/// 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.
///
/// # Panics
/// Panics on bad bucket bounds, a malformed date or mixed currencies.
pub fn aged_debt(invoices: &[OpenInvoice], as_at: &str, bucket_days: &[i64], currency: &str) -> AgedDebt {
    if bucket_days.is_empty() {
        panic!("bucketDays needs at least one boundary");
    }
    let mut previous = 0;
    for &bound in bucket_days {
        if bound <= previous {
            panic!(
                "bucketDays must be whole days, ascending, from 1: received {} after {}",
                bound, previous
            );
        }
        previous = bound;
    }

    let zero = money(0, currency);
    let mut buckets: Vec<AgedDebtBucket> = vec![AgedDebtBucket {
        label: "current".to_string(),
        from_days: None,
        to_days: Some(0),
        total: zero.clone(),
        references: Vec::new(),
    }];
    let mut start = 1;
    for &bound in bucket_days {
        buckets.push(AgedDebtBucket {
            label: format!("{}-{}", start, bound),
            from_days: Some(start),
            to_days: Some(bound),
            total: zero.clone(),
            references: Vec::new(),
        });
        start = bound + 1;
    }
    buckets.push(AgedDebtBucket {
        label: format!("{}+", start - 1),
        from_days: Some(start),
        to_days: None,
        total: zero.clone(),
        references: Vec::new(),
    });

    let mut total = zero;
    for invoice in invoices {
        let overdue = days_between(&invoice.due_date, as_at);
        let mut index = 0;
        if overdue > 0 {
            index = bucket_days.len() + 1;
            for (i, &bound) in bucket_days.iter().enumerate() {
                if overdue <= bound {
                    index = i + 1;
                    break;
                }
            }
        }
        let bucket = &mut buckets[index];
        bucket.total = add_money(&bucket.total, &invoice.outstanding);
        bucket.references.push(invoice.reference.clone());
        total = add_money(&total, &invoice.outstanding);
    }
    AgedDebt {
        as_at: as_at.to_string(),
        buckets,
        total,
    }
}

fn optional_int(value: Option<i64>) -> Value {
    match value {
        Some(n) => Value::Int(n),
        None => Value::Null,
    }
}

pub fn open_invoice_from_value(v: &Value) -> OpenInvoice {
    OpenInvoice {
        reference: v.get("reference").as_str().to_string(),
        due_date: v.get("dueDate").as_str().to_string(),
        outstanding: money_from_value(v.get("outstanding")),
    }
}

pub fn aged_debt_to_value(report: &AgedDebt) -> Value {
    Value::obj(vec![
        ("asAt", Value::str(&report.as_at)),
        (
            "buckets",
            Value::Arr(
                report
                    .buckets
                    .iter()
                    .map(|b| {
                        Value::obj(vec![
                            ("label", Value::str(&b.label)),
                            ("fromDays", optional_int(b.from_days)),
                            ("toDays", optional_int(b.to_days)),
                            ("total", money_to_value(&b.total)),
                            ("references", Value::Arr(b.references.iter().map(|r| Value::str(r)).collect())),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("total", money_to_value(&report.total)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let invoices: Vec<OpenInvoice> = args[0].as_arr().iter().map(open_invoice_from_value).collect();
    let bounds: Vec<i64> = args[2].as_arr().iter().map(|v| v.as_i64()).collect();
    aged_debt_to_value(&aged_debt(&invoices, args[1].as_str(), &bounds, args[3].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add finance.aged-debt
Download for Rust finance.aged-debt-1.0.0-rust.fune · 18,252 bytes sha256 10b357f0f76d8d1d0cadae37fce1931f21e28c1e775e56dd2f0f1a0fe3de9dd4

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./finance.aged-debt-1.0.0-rust.fune, or fetch it from a terminal with fune pull finance.aged-debt@1.0.0:rust.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,457
impl/python.py2,252
impl/rust.rs4,122
impl/typescript.ts2,211
vectors.json7,733