Functional Weave
Code in Rust

banking.overdraft-interest

Overdraft interest for a statement period from daily balances, with an interest-free buffer.

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

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

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 interest on a current account's overdraft for one statement period, from its balance history. Each day the account is overdrawn, the amount overdrawn beyond an interest-free buffer accrues interest at the overdraft's annual rate; days in credit, and the part inside the buffer, cost nothing. It also counts the days overdrawn and the days actually charged, which statements show.

## How it is computed

For example

  • overdraft_interest(balances ×1, 2026-03-01, 2026-04-01, 39.9%, £0.00, act-365f, half-up) → interest £16.94, days overdrawn 31, days charged 31 £500 overdrawn all month at 39.9%, no buffer
  • overdraft_interest(balances ×1, 2026-03-01, 2026-04-01, 39.9%, £250.00, act-365f, half-up) → interest £8.47, days overdrawn 31, days charged 31 a £250 buffer: only the £250 above it is charged
  • overdraft_interest(balances ×1, 2026-03-01, 2026-04-01, 39.9%, £250.00, act-365f, half-up) → interest £0.00, days overdrawn 31, days charged 0 within the buffer all month: no interest

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 overdraft_interest(balances: &[DatedBalance], from_iso: &str, to_iso: &str, annual_rate_basis_points: i64, interest_free_buffer: &Money, convention: &str, mode: &str) -> OverdraftInterest
balancesDatedBalance[]the account balance and the date each takes effect, ascending; negative when overdrawn
from_isodatefirst day of the statement period, included
to_isodateend of the period, excluded
annual_rate_basis_pointsintthe overdraft's annual interest rate, 0 or more; 3990 = 39.9%
interest_free_bufferMoneythe overdrawn amount that is free of interest, e.g. the first £250; zero for none
conventionDayCountConventionthe day count basis, usually act-365f
modeRoundingModehow the exact interest becomes whole minor units
returnsOverdraftInterest

The type it declares, generated into your project

/// The charge for the period and how many days drove it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverdraftInterest {
    /// interest to charge, zero or more
    pub interest: Money,
    /// days the balance was below zero
    pub days_overdrawn: i64,
    /// days the overdrawn amount was above the buffer
    pub days_charged: i64,
}

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

fune!(banking.overdraft-interest@^1);  // then call overdraft_interest(…)
impl/rust.rs · 88 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::lending_daily_interest::{daily_interest, dated_balance_from_value, DatedBalance};  ← from lending.daily-interest ^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

/// Overdraft interest over a statement period. Each day the account is
/// overdrawn, the part of the overdrawn amount above the interest-free buffer
/// accrues interest; credit balances earn nothing here. The accrual itself is
/// lending.daily-interest on those chargeable amounts, so it is summed exactly
/// and rounded once for the period.
///
/// # Panics
/// Panics on a negative rate or buffer, mixed currencies, or anything
/// lending.daily-interest refuses.
pub fn overdraft_interest(
    balances: &[DatedBalance],
    from_iso: &str,
    to_iso: &str,
    annual_rate_basis_points: i64,
    interest_free_buffer: &Money,
    convention: &str,
    mode: &str,
) -> OverdraftInterest {
    if annual_rate_basis_points < 0 {
        panic!("annualRateBasisPoints must not be negative, received {}", annual_rate_basis_points);
    }
    if interest_free_buffer.minor < 0 {
        panic!("interestFreeBuffer must not be negative, received {}", interest_free_buffer.minor);
    }
    for entry in balances {
        if entry.balance.currency != interest_free_buffer.currency {
            panic!("currency mismatch: {} and {}", entry.balance.currency, interest_free_buffer.currency);
        }
    }
    let chargeable: Vec<DatedBalance> = balances
        .iter()
        .map(|entry| DatedBalance {
            date: entry.date.clone(),
            balance: money((-entry.balance.minor - interest_free_buffer.minor).max(0), &entry.balance.currency),
        })
        .collect();
    // Validates the dates, their order and the period before anything is counted.
    let accrued = daily_interest(&chargeable, from_iso, to_iso, annual_rate_basis_points, convention, mode);
    let mut days_overdrawn = 0i64;
    let mut days_charged = 0i64;
    for i in 0..balances.len() {
        let date = balances[i].date.as_str();
        let start = if date > from_iso { date } else { from_iso };
        let next = if i + 1 < balances.len() { balances[i + 1].date.as_str() } else { to_iso };
        let end = if next < to_iso { next } else { to_iso };
        if start >= end {
            continue;
        }
        let days = days_between(start, end);
        if balances[i].balance.minor < 0 {
            days_overdrawn += days;
        }
        if chargeable[i].balance.minor > 0 {
            days_charged += days;
        }
    }
    OverdraftInterest {
        interest: accrued.interest,
        days_overdrawn,
        days_charged,
    }
}

pub fn overdraft_interest_to_value(result: &OverdraftInterest) -> Value {
    Value::obj(vec![
        ("interest", money_to_value(&result.interest)),
        ("daysOverdrawn", Value::Int(result.days_overdrawn)),
        ("daysCharged", Value::Int(result.days_charged)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let balances: Vec<DatedBalance> = args[0].as_arr().iter().map(dated_balance_from_value).collect();
    overdraft_interest_to_value(&overdraft_interest(
        &balances,
        args[1].as_str(),
        args[2].as_str(),
        args[3].as_i64(),
        &money_from_value(&args[4]),
        args[5].as_str(),
        args[6].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 5 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 banking.overdraft-interest
Download for Rust banking.overdraft-interest-1.0.0-rust.fune · 16,799 bytes sha256 22c93eef23c8a1e3ed0b32cf2bc5ace36760ab7721a716634e33bde9889276c6

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

The whole function, every language, is one file too: banking.overdraft-interest-1.0.0.fune, 22,072 bytes, sha256 bf4af41ce4dae6ab3e75bc8132ae144f69be637d26bfcd42deac96d7e9ca6b50. 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 banking.overdraft-interest

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

// fune: after banking.overdraft-interest

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.day-count-fraction in banking.overdraft-interest
// fune: replace dates.days-between in banking.overdraft-interest
// fune: replace lending.daily-interest in banking.overdraft-interest
// fune: replace math.round-div in banking.overdraft-interest
// fune: replace money.amount in banking.overdraft-interest

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 banking.overdraft-interest --steps.

// fune: step banking.overdraft-interest 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
£500 overdrawn all month at 39.9%, no buffer balances ×1, 2026-03-01, 2026-04-01, 39.9%, £0.00, act-365f, half-up → interest £16.94, days overdrawn 31, days charged 31
a £250 buffer: only the £250 above it is charged balances ×1, 2026-03-01, 2026-04-01, 39.9%, £250.00, act-365f, half-up → interest £8.47, days overdrawn 31, days charged 31
within the buffer all month: no interest balances ×1, 2026-03-01, 2026-04-01, 39.9%, £250.00, act-365f, half-up → interest £0.00, days overdrawn 31, days charged 0
in and out of the overdraft during the month balances ×4, 2026-03-01, 2026-04-01, 39.9%, £250.00, act-365f, half-up → interest £6.29, days overdrawn 15, days charged 15
in credit all month: no interest and no overdrawn days balances ×1, 2026-03-01, 2026-04-01, 39.9%, £0.00, act-365f, half-up → interest £0.00, days overdrawn 0, days charged 0
a balance exactly at the buffer is not charged balances ×1, 2026-03-01, 2026-04-01, 39.9%, £250.00, act-365f, half-up → interest £0.00, days overdrawn 31, days charged 0
opening balance from before the statement balances ×2, 2026-03-01, 2026-04-01, 19.9%, £0.00, act-365f, half-up → interest £7.63, days overdrawn 14, days charged 14
a zero-rate overdraft counts days but charges nothing balances ×1, 2026-03-01, 2026-04-01, 0%, £0.00, act-365f, half-up → interest £0.00, days overdrawn 31, days charged 31
customer-friendly rounding down balances ×1, 2026-03-01, 2026-03-08, 39.9%, £0.00, act-365f, down → interest £0.94, days overdrawn 7, days charged 7
a leap-year February on ACT/365F balances ×1, 2028-02-01, 2028-03-01, 39.9%, £0.00, act-365f, half-up → interest £31.70, days overdrawn 29, days charged 29
Show the other 5 tests
CaseArgumentsExpected
a negative rate is refused balances ×1, 2026-03-01, 2026-04-01, -0.01%, £0.00, act-365f, half-up → error: annualRateBasisPoints must not be negative
a negative buffer is refused balances ×1, 2026-03-01, 2026-04-01, 39.9%, -£0.01, act-365f, half-up → error: interestFreeBuffer must not be negative
a buffer in another currency is refused balances ×1, 2026-03-01, 2026-04-01, 39.9%, €0.00, act-365f, half-up → error: currency mismatch
balances out of order are refused balances ×2, 2026-03-01, 2026-04-01, 39.9%, £0.00, act-365f, half-up → error: strictly ascending date order
a first balance after the statement starts is refused balances ×1, 2026-03-01, 2026-04-01, 39.9%, £0.00, act-365f, half-up → error: after fromIso

More from the author

The balance history is turned into a history of chargeable amounts, max(0, overdrawn − buffer), and handed to lending.daily-interest: each amount accrues for the days it held under the day-count convention, the pieces are summed as exact fractions, and the total is rounded once with the mode you pass. The period is `fromIso` included to `toIso` excluded, and the balance list follows lending.daily-interest's rules (ascending dates, the first on or before `fromIso`).

## The buffer

An interest-free buffer (for example "the first £250 of your arranged overdraft is interest-free") is modelled the common way: only the amount **above** the buffer is charged, every day. A balance exactly at the buffer is not charged. Some accounts instead charge the whole overdrawn amount once it passes a threshold; that is not this rule, and is modelled by passing a zero buffer for the days above the threshold.

## Context

Since April 2020 UK banks price arranged and unarranged overdrafts with a single simple annual interest rate and no fixed daily or monthly fees (FCA, PS19/16 "High-cost credit review: overdrafts", June 2019, https://www.fca.org.uk/publication/policy/ps19-16.pdf), which is exactly what this computes. The rate, buffer, day count and rounding are the account's terms, so they are arguments; ACT/365F and rounding the period's interest to the nearest penny are the usual choices. Credit interest on the same account is lending.daily-interest on the positive balances.

## What it does not do

- Tiered rates (a different rate above some amount): call once per tier with a buffer at the tier's floor and subtract. - Charges other than interest, and monthly caps on charges (see banking.fees-cap). - Rate changes within the period: split the period at the change.

Files

PathBytes
README.md2,221
impl/python.py2,553
impl/rust.rs3,378
impl/typescript.ts2,552
vectors.json6,868