lending.amortisation-schedule Unreviewed
Repayment schedule for a level-payment loan: interest, principal and balance per period, ending at exactly zero.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 13 tests, run in TypeScript, Python and Rust.
Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
Not professional advice. This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
The repayment table for a level-payment loan: for every period, the payment, the interest, the part that repays the loan, and the balance left. It is the table a mortgage offer or a loan statement shows, built the way the lender's ledger builds it.
## How each row is made
For example
amortisation_schedule(£1,000.00, 12%, 12, 12, half-up)→ payment £88.85, rows ×12, total interest £66.19, total paid £1,066.19 £1,000 at 12% over 12 months, half-upamortisation_schedule(£2,000.00, 9.99%, 12, 12, up)→ payment £175.83, rows ×12, total interest £109.85, total paid £2,109.85 rounded up, the final payment is smalleramortisation_schedule(£1,000.00, 12%, 12, 12, down)→ payment £88.84, rows ×12, total interest £66.19, total paid £1,066.19 rounded down, the final payment is larger
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 amortisation_schedule(principal: &Money, annual_rate_basis_points: i64, term_months: i64, payments_per_year: i64, mode: &str) -> AmortisationSchedule
| principal | Money | the amount borrowed, greater than zero |
| annual_rate_basis_points | int | nominal annual rate, 0 to 100000 |
| term_months | int | the term; it must hold a whole number of payments |
| payments_per_year | int | 1 to 52 |
| mode | RoundingMode | rounding of the level payment, as lending.loan-payment |
| returns | AmortisationSchedule |
The types it declares, generated into your project
/// One period of the schedule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AmortisationRow {
/// 1 for the first payment
pub period: i64,
/// the level payment, or the adjusted final one
pub payment: Money,
/// interest for the period on the opening balance
pub interest: Money,
/// the part of the payment that repays the loan
pub principal: Money,
/// the balance after this payment
pub balance: Money,
}
/// The level payment, every period, and the totals.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AmortisationSchedule {
pub payment: Money,
pub rows: Vec<AmortisationRow>,
pub total_interest: Money,
pub total_paid: Money,
}
Your code names it in one line, in the file that uses it
fune!(lending.amortisation-schedule@^1); // then call amortisation_schedule(…)
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::lending_loan_payment::{loan_payment, payment_count}; ← from lending.loan-payment ^1.0.0 · built alongside by fune
use super::math_round_div::round_div; ← from math.round-div ^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
/// Interest for one period on a positive balance: balance × b / D, rounded
/// half-up to the minor unit, which is what gets posted to the account.
pub fn period_interest(balance: i64, annual_rate_basis_points: i64, payments_per_year: i64) -> i64 {
if balance <= 0 || annual_rate_basis_points == 0 {
return 0;
}
let product = balance as i128 * annual_rate_basis_points as i128;
if product > i64::MAX as i128 {
panic!("balance too large for exact interest");
}
round_div(product as i64, 10000 * payments_per_year, "half-up")
}
/// The schedule a lender's system produces: each period's interest is
/// computed on the opening balance and rounded to a whole minor unit, the
/// level payment repays interest first and principal with the rest, and the
/// last payment is whatever clears the balance, so it ends at exactly zero.
///
/// # Panics
/// Panics on the same arguments lending.loan-payment refuses.
pub fn amortisation_schedule(
principal: &Money,
annual_rate_basis_points: i64,
term_months: i64,
payments_per_year: i64,
mode: &str,
) -> AmortisationSchedule {
let payment = loan_payment(principal, annual_rate_basis_points, term_months, payments_per_year, mode);
let n = payment_count(annual_rate_basis_points, term_months, payments_per_year);
let currency = principal.currency.as_str();
let mut rows: Vec<AmortisationRow> = Vec::new();
let mut balance = principal.minor;
let mut total_interest = 0i64;
let mut total_paid = 0i64;
let mut period = 1i64;
while period <= n && balance > 0 {
let interest = period_interest(balance, annual_rate_basis_points, payments_per_year);
// The last period, or one where the level payment would overshoot (a
// rounded-up payment can clear the loan a period early), pays exactly
// what is owed.
let due = if period == n || balance + interest <= payment.minor {
balance + interest
} else {
payment.minor
};
let repaid = due - interest;
balance -= repaid;
total_interest += interest;
total_paid += due;
rows.push(AmortisationRow {
period,
payment: money(due, currency),
interest: money(interest, currency),
principal: money(repaid, currency),
balance: money(balance, currency),
});
period += 1;
}
AmortisationSchedule {
payment,
rows,
total_interest: money(total_interest, currency),
total_paid: money(total_paid, currency),
}
}
pub fn amortisation_row_to_value(row: &AmortisationRow) -> Value {
Value::obj(vec![
("period", Value::Int(row.period)),
("payment", money_to_value(&row.payment)),
("interest", money_to_value(&row.interest)),
("principal", money_to_value(&row.principal)),
("balance", money_to_value(&row.balance)),
])
}
pub fn amortisation_schedule_to_value(schedule: &AmortisationSchedule) -> Value {
Value::obj(vec![
("payment", money_to_value(&schedule.payment)),
("rows", Value::Arr(schedule.rows.iter().map(amortisation_row_to_value).collect())),
("totalInterest", money_to_value(&schedule.total_interest)),
("totalPaid", money_to_value(&schedule.total_paid)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
amortisation_schedule_to_value(&amortisation_schedule(
&money_from_value(&args[0]),
args[1].as_i64(),
args[2].as_i64(),
args[3].as_i64(),
args[4].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 lending.amortisation-schedule
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./lending.amortisation-schedule-1.0.1-rust.fune, or fetch it from a terminal with fune pull lending.amortisation-schedule@1.0.1:rust.
The whole function, every language, is one file too: lending.amortisation-schedule-1.0.1.fune, 66,045 bytes, sha256 d20048fdaec868479307700ac9f444968a01d572c7be5809edaf48096ef9f96c. 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 lending.amortisation-schedule
after — your function gets the result and the arguments, and returns the final result.
// fune: after lending.amortisation-schedule
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 lending.loan-payment in lending.amortisation-schedule
// fune: replace math.round-div in lending.amortisation-schedule
// fune: replace money.amount in lending.amortisation-schedule
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 lending.amortisation-schedule --steps.
// fune: step lending.amortisation-schedule 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 | |
|---|---|---|---|
| £1,000 at 12% over 12 months, half-up | £1,000.00, 12%, 12, 12, half-up | → | payment £88.85, rows ×12, total interest £66.19, total paid £1,066.19 |
| rounded up, the final payment is smaller | £2,000.00, 9.99%, 12, 12, up | → | payment £175.83, rows ×12, total interest £109.85, total paid £2,109.85 |
| rounded down, the final payment is larger | £1,000.00, 12%, 12, 12, down | → | payment £88.84, rows ×12, total interest £66.19, total paid £1,066.19 |
| £5,000 at 6% over 6 months | £5,000.00, 6%, 6, 12, half-up | → | payment £847.98, rows ×6, total interest £87.87, total paid £5,087.87 |
| interest-free: the pennies left over go on the last payment | £1,000.00, 0%, 12, 12, down | → | payment £83.33, rows ×12, total interest £0.00, total paid £1,000.00 |
| rounded up, a tiny interest-free loan clears three payments early | £0.25, 0%, 12, 12, up | → | payment £0.03, rows ×9, total interest £0.00, total paid £0.25 |
| a single annual payment | £1,000.00, 10%, 12, 1, half-up | → | payment £1,100.00, rows ×1, total interest £100.00, total paid £1,100.00 |
| quarterly over two years at 8% | £10,000.00, 8%, 24, 4, half-up | → | payment £1,365.10, rows ×8, total interest £920.80, total paid £10,920.80 |
| a small loan where rounding dominates | £10.00, 19.99%, 12, 12, half-up | → | payment £0.93, rows ×12, total interest £1.10, total paid £11.10 |
| dollars, monthly for three years at 7.5% | $15,000.00, 7.5%, 36, 12, half-up | → | payment $466.59, rows ×36, total interest $1,797.36, total paid $16,797.36 |
Show the other 3 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a zero principal is refused | £0.00, 5%, 12, 12, half-up | → | error: principal must be greater than zero |
| a term that is not a whole number of payments | £1,000.00, 5%, 7, 4, half-up | → | error: is not a whole number of payments |
| a negative rate is refused | £1,000.00, -0.5%, 12, 12, half-up | → | error: annualRateBasisPoints must be between 0 and 100000 |
More from the author
1. The level payment comes from lending.loan-payment, rounded with the mode you pass. 2. Each period's interest is the opening balance × rate / paymentsPerYear, rounded half-up to a whole minor unit: that is the amount actually posted. 3. The payment pays that interest first; the rest reduces the balance. 4. The final payment is not the level payment. It is whatever is owed: the remaining balance plus that period's interest. So the balance ends at exactly zero, never at -3p or +2p.
The final payment is where every penny of rounding collects. With the payment rounded half-up it is within a few pence of the others; rounded `up` it is a little smaller, rounded `down` a little larger. If a rounded-up payment would clear the loan before the term ends (possible only for tiny loans, such as 25p over 12 months), the schedule stops at the payment that clears it, so it can have fewer rows than the term has payments. It never has a row with a payment of zero.
The totals are the sums of the posted rows: totalPaid = principal + totalInterest, exactly.
## What it does not do
The periods are equal and the rate is nominal and fixed, as in lending.loan-payment. It does not model daily interest, payment holidays, rate changes, fees or overpayments (lending.overpayment-effect covers a one-off overpayment).
## Limits
The same arguments as lending.loan-payment, so at most 3000 rows. A balance whose interest product (balance × basis points) would exceed 2^63 - 1 is an error ("balance too large for exact interest") in every language.
The module also exports `periodInterest(balance, annualRateBasisPoints, paymentsPerYear)`, the rounding rule of step 2.
## Before you rely on this
**Not professional advice.** This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.
**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
1.0.1 marks it unreviewed. The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| README.md | 3,116 |
| impl/python.py | 2,823 |
| impl/rust.rs | 3,817 |
| impl/typescript.ts | 2,672 |
| vectors.json | 42,733 |