Functional Weave
Code in Rust

education.fee-instalments

Tuition fee instalment schedule from a total, shares and due dates, exact to the penny.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 20 tests, run in TypeScript, Python and Rust.

What it does

A payment schedule for a tuition fee: the total split into instalments by the shares given (25/25/50 across three terms, equal thirds, ten monthly payments), each with its due date, the running total paid and what is left. The amounts always add up to the fee exactly.

## Decisions

For example

  • fee_instalments(£9,535.00, 1, 1, 1, 2025-10-01, 2026-01-15, 2026-04-20) → ×3 £9,535 in equal thirds: the odd penny on the first instalment
  • fee_instalments(£9,535.00, 25, 25, 50, 2025-10-01, 2026-01-15, 2026-04-20) → ×3 £9,535 split 25/25/50 across three terms
  • fee_instalments(£1,000.01, 25, 25, 50, 2025-10-01, 2026-01-15, 2026-04-20) → ×3 £1,000.01 split 25/25/50: the odd penny goes to the 50% share, the largest remainder, not the first

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 fee_instalments(total: &Money, shares: &[i64], due_dates: &[String]) -> Vec<FeeInstalment>
totalMoneythe fee to collect, 0 or more
sharesint[]one relative share per instalment: [25, 25, 50], or [1, 1, 1] for equal thirds
due_datesdate[]one per instalment, in strictly ascending order
returnsFeeInstalment[]the instalments in date order, summing exactly to total

The type it declares, generated into your project

/// One payment in the schedule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeeInstalment {
    /// 1 for the first
    pub number: i64,
    pub due_date: String,
    pub amount: Money,
    /// paid once this instalment is in
    pub cumulative: Money,
    /// still owed after it
    pub remaining: Money,
}

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

fune!(education.fee-instalments@^1);  // then call fee_instalments(…)
impl/rust.rs · 90 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_add_days::parse_iso_date;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::money_allocate::allocate;  ← from money.allocate ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

/// Split a fee into dated instalments that add up to it exactly.
///
/// The split is money.allocate's: floors, then the leftover pennies to the
/// largest remainders, ties to the earlier instalment.
///
/// # Panics
/// Panics on a negative total, mismatched or empty lists, negative or all-zero
/// shares, or due dates that are malformed or not strictly ascending.
pub fn fee_instalments(total: &Money, shares: &[i64], due_dates: &[String]) -> Vec<FeeInstalment> {
    if total.minor < 0 {
        panic!("total must not be negative, received {}", total.minor);
    }
    if shares.is_empty() {
        panic!("shares must name at least one instalment");
    }
    if shares.len() != due_dates.len() {
        panic!(
            "shares and dueDates must be the same length, received {} and {}",
            shares.len(),
            due_dates.len()
        );
    }
    for &s in shares {
        if s < 0 {
            panic!("each share must be a whole number of 0 or more, received {}", s);
        }
    }
    if shares.iter().sum::<i64>() == 0 {
        panic!("shares must not all be zero");
    }
    for i in 0..due_dates.len() {
        parse_iso_date(&due_dates[i]);
        if i > 0 && due_dates[i] <= due_dates[i - 1] {
            panic!(
                "dueDates must be in strictly ascending order: {} follows {}",
                due_dates[i],
                due_dates[i - 1]
            );
        }
    }
    let amounts = allocate(total, shares);
    let mut paid = 0;
    amounts
        .into_iter()
        .enumerate()
        .map(|(i, amount)| {
            paid += amount.minor;
            FeeInstalment {
                number: i as i64 + 1,
                due_date: due_dates[i].clone(),
                amount,
                cumulative: money(paid, &total.currency),
                remaining: money(total.minor - paid, &total.currency),
            }
        })
        .collect()
}

pub fn fee_instalment_to_value(f: &FeeInstalment) -> Value {
    Value::obj(vec![
        ("number", Value::Int(f.number)),
        ("dueDate", Value::str(&f.due_date)),
        ("amount", money_to_value(&f.amount)),
        ("cumulative", money_to_value(&f.cumulative)),
        ("remaining", money_to_value(&f.remaining)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let shares: Vec<i64> = args[1]
        .as_arr()
        .iter()
        .map(|v| match v {
            Value::Float(f) if f.fract() != 0.0 => panic!("each share must be a whole number of 0 or more, received {}", f),
            other => other.as_i64(),
        })
        .collect();
    let dates: Vec<String> = args[2].as_arr().iter().map(|v| v.as_str().to_string()).collect();
    Value::Arr(
        fee_instalments(&money_from_value(&args[0]), &shares, &dates)
            .iter()
            .map(fee_instalment_to_value)
            .collect(),
    )
}

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 education.fee-instalments
Download for Rust education.fee-instalments-1.0.0-rust.fune · 24,281 bytes sha256 e55648f02d927ba97b26ac02ccf09a67dcf5daa2dcfe62352f812508ab34a2a5

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

The whole function, every language, is one file too: education.fee-instalments-1.0.0.fune, 28,265 bytes, sha256 dc792a881b37a71d994e1a7d24c0855ee8441b1914f760b7ea9661aee50b223f. 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 education.fee-instalments

after — your function gets the result and the arguments, and returns the final result.

// fune: after education.fee-instalments

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.add-days in education.fee-instalments
// fune: replace money.allocate in education.fee-instalments
// fune: replace money.amount in education.fee-instalments

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 education.fee-instalments --steps.

// fune: step education.fee-instalments 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
£9,535 in equal thirds: the odd penny on the first instalment £9,535.00, 1, 1, 1, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
£9,535 split 25/25/50 across three terms £9,535.00, 25, 25, 50, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
£1,000.01 split 25/25/50: the odd penny goes to the 50% share, the largest remainder, not the first £1,000.01, 25, 25, 50, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
shares in basis points give the same schedule £1,000.01, 2,500, 2,500, 5,000, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
£100 in thirds: 33.34, 33.33, 33.33 £100.00, 1, 1, 1, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
£100.01 in thirds: the two leftover pennies to the first two £100.01, 1, 1, 1, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
a single payment in full £9,250.00, 1, 2025-09-22 → ×1
a zero share collects nothing £9,000.00, 0, 1, 1, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
a zero fee is a schedule of zeros £0.00, 1, 1, 2025-10-01, 2026-01-15 → ×2
euros keep their currency €10.00, 1, 1, 1, 2025-10-01, 2026-01-15, 2026-04-20 → ×3
Show the other 10 tests
CaseArgumentsExpected
ten monthly payments of £925 £9,250.00, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2025-10-01, 2025-11-01, 2025-12-01, 2026-01-01, 2026-02-01, 2026-03-01, 2026-04-01, 2026-05-01, 2026-06-01, 2026-07-01 → ×10
a negative total is an error -£1.00, 1, 1, 2025-10-01, 2026-01-15 → error: total must not be negative, received -100
more shares than dates is an error £1.00, 1, 1, 1, 2025-10-01, 2026-01-15 → error: shares and dueDates must be the same length, received 3 and 2
no instalments is an error £1.00, , → error: shares must name at least one instalment
a negative share is an error £1.00, 1, -1, 2025-10-01, 2026-01-15 → error: each share must be a whole number of 0 or more, received -1
a fractional share is an error £1.00, 1, 1.5, 2025-10-01, 2026-01-15 → error: each share must be a whole number of 0 or more, received 1.5
all-zero shares are an error £1.00, 0, 0, 2025-10-01, 2026-01-15 → error: shares must not all be zero
dates out of order are an error £1.00, 1, 1, 2026-01-15, 2025-10-01 → error: dueDates must be in strictly ascending order: 2025-10-01 follows 2026-01-15
two instalments on one day are an error £1.00, 1, 1, 2025-10-01, 2025-10-01 → error: dueDates must be in strictly ascending order
an impossible due date is an error £1.00, 1, 2026-02-30 → error: "2026-02-30" is not a real calendar date

More from the author

- **Split by `money.allocate`.** Each instalment gets its share rounded down, and the pennies left over go to the instalments with the largest remainders, ties to the earlier one. So £9,535 in thirds is £3,178.34, £3,178.33, £3,178.33, and £1,000.01 split 25/25/50 puts the odd penny on the 50% instalment, where the largest fraction of a penny was, not on the first. Adding a remainder to the first or last instalment by habit gives a different schedule from this one; say so if yours must match another system. - **Shares are relative**, so [25, 25, 50], [1, 1, 2] and [2500, 2500, 5000] give the same schedule. A share of 0 is allowed (an instalment that collects nothing, say a deposit already paid) but not all of them. - **Due dates are the caller's**, strictly ascending: universities and schools set them per term or month, so there is no rule to derive them from. Use `dates.add-months` or `dates.recurrence` to generate monthly dates. - Fees are never negative; a refund is not a schedule.

Files

PathBytes
README.md1,340
impl/python.py1,978
impl/rust.rs3,058
impl/typescript.ts1,842
vectors.json14,820