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
feeInstalments(£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 instalmentfeeInstalments(£9,535.00, 25, 25, 50, 2025-10-01, 2026-01-15, 2026-04-20)→ ×3 £9,535 split 25/25/50 across three termsfeeInstalments(£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.
export function feeInstalments(total: Money, shares: readonly number[], dueDates: readonly string[]): readonly FeeInstalment[]
| total | Money | the fee to collect, 0 or more |
| shares | int[] | one relative share per instalment: [25, 25, 50], or [1, 1, 1] for equal thirds |
| dueDates | date[] | one per instalment, in strictly ascending order |
| returns | FeeInstalment[] | the instalments in date order, summing exactly to total |
The type it declares, generated into your project
/** One payment in the schedule. */
export interface FeeInstalment {
/** 1 for the first */
readonly number: number;
readonly dueDate: string;
readonly amount: Money;
/** paid once this instalment is in */
readonly cumulative: Money;
/** still owed after it */
readonly remaining: Money;
}
Your code names it in one line, in the file that uses it
import { feeInstalments } from "#fune/education.fee-instalments@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { allocate } from "./money_allocate.ts"; ← from money.allocate ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type FeeInstalment } from "./education_fee_instalments_types.ts";
/**
* 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.
*/
export function feeInstalments(total: Money, shares: readonly number[], dueDates: readonly string[]): readonly FeeInstalment[] {
if (!Number.isInteger(total.minor) || total.minor < 0) {
throw new RangeError(`total must not be negative, received ${total.minor}`);
}
if (shares.length === 0) {
throw new RangeError("shares must name at least one instalment");
}
if (shares.length !== dueDates.length) {
throw new RangeError(`shares and dueDates must be the same length, received ${shares.length} and ${dueDates.length}`);
}
let sum = 0;
for (const s of shares) {
if (!Number.isInteger(s) || s < 0) {
throw new RangeError(`each share must be a whole number of 0 or more, received ${s}`);
}
sum += s;
}
if (sum === 0) {
throw new RangeError("shares must not all be zero");
}
for (let i = 0; i < dueDates.length; i++) {
parseIsoDate(dueDates[i]);
if (i > 0 && dueDates[i] <= dueDates[i - 1]) {
throw new RangeError(`dueDates must be in strictly ascending order: ${dueDates[i]} follows ${dueDates[i - 1]}`);
}
}
const amounts = allocate(total, shares);
let paid = 0;
return amounts.map((amount, i) => {
paid += amount.minor;
return {
number: i + 1,
dueDate: dueDates[i],
amount,
cumulative: money(paid, total.currency),
remaining: money(total.minor - paid, total.currency),
};
});
}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 education.fee-instalments
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./education.fee-instalments-1.0.0-typescript.fune, or fetch it from a terminal with fune pull education.fee-instalments@1.0.0:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| £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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,340 |
| impl/python.py | 1,978 |
| impl/rust.rs | 3,058 |
| impl/typescript.ts | 1,842 |
| vectors.json | 14,820 |