Functional Weave
Code in Rust

banking.fees-cap

Apply a monthly fee cap to a list of account charges: charge up to the cap, waive the rest.

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

Pinned by 18 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

Applies a monthly cap to the charges raised on an account: charges are taken in date order until the cap is used up, the charge that crosses the cap is cut down to what is left, and every later charge in the same period is waived. Each charge comes back split into `charged` and `waived` (the two always add up to the amount raised), in the order the caller passed them, with totals.

## Why

For example

  • apply_fee_cap(charges ×2, £20.00, 1) → charges ×2, total charged £10.00, total waived £0.00 under the cap: everything is charged
  • apply_fee_cap(charges ×2, £20.00, 1) → charges ×2, total charged £20.00, total waived £0.00 exactly at the cap: nothing is waived
  • apply_fee_cap(charges ×4, £20.00, 1) → charges ×4, total charged £20.00, total waived £12.00 the charge that crosses the cap is reduced, later ones waived

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 apply_fee_cap(charges: &[FeeCharge], monthly_cap: &Money, cycle_day: i64) -> FeeCapResult
chargesFeeCharge[]every charge raised, in any order; each is capped within its own charging period
monthly_capMoneythe most that may be charged in one charging period; the bank's own figure
cycle_dayintday of the month each charging period starts, 1 to 28; 1 is the calendar month
returnsFeeCapResultthe charges in input order, each split into what is charged and what is waived

The types it declares, generated into your project

/// One charge as raised, before any cap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeeCharge {
    pub date: String,
    /// e.g. "Unarranged overdraft fee"
    pub label: String,
    /// 0 or more
    pub amount: Money,
}

/// One charge after the cap: charged plus waived is the amount raised.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CappedCharge {
    pub date: String,
    pub label: String,
    pub amount: Money,
    pub charged: Money,
    pub waived: Money,
}

/// The capped charges and their totals.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeeCapResult {
    pub charges: Vec<CappedCharge>,
    pub total_charged: Money,
    pub total_waived: Money,
}

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

fune!(banking.fees-cap@^1);  // then call apply_fee_cap(…)
impl/rust.rs · 128 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_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

/// The charging period a date falls in, as (year, month) of its start. With
/// `cycle_day` 15, 2026-03-14 belongs to the period that began on 2026-02-15.
fn period_key(iso: &str, cycle_day: i64) -> (i64, i64) {
    let d = parse_iso_date(iso);
    let (mut year, mut month) = (d.year, d.month);
    if d.day < cycle_day {
        month -= 1;
        if month == 0 {
            month = 12;
            year -= 1;
        }
    }
    (year, month)
}

/// Apply a monthly cap to a list of charges.
///
/// Within each charging period the charges are taken in date order (ties in
/// input order) until the cap is used up: the charge that crosses the cap is
/// reduced to what is left, and every later charge in that period is waived in
/// full. The result keeps the input order.
///
/// # Panics
/// Panics on a cycle day outside 1-28, a negative cap or charge, a currency
/// mismatch, or a malformed date.
pub fn apply_fee_cap(charges: &[FeeCharge], monthly_cap: &Money, cycle_day: i64) -> FeeCapResult {
    if !(1..=28).contains(&cycle_day) {
        panic!("cycleDay must be 1 to 28, received {}", cycle_day);
    }
    if monthly_cap.minor < 0 {
        panic!("monthlyCap must not be negative, received {}", monthly_cap.minor);
    }
    let currency = monthly_cap.currency.as_str();
    let mut keys: Vec<(i64, i64)> = Vec::new();
    for charge in charges {
        if charge.amount.currency != currency {
            panic!("currency mismatch: {} and {}", charge.amount.currency, currency);
        }
        if charge.amount.minor < 0 {
            panic!("charge \"{}\" must not be negative, received {}", charge.label, charge.amount.minor);
        }
        keys.push(period_key(&charge.date, cycle_day));
    }

    // sort_by is stable, so equal dates keep their input order.
    let mut order: Vec<usize> = (0..charges.len()).collect();
    order.sort_by(|a, b| charges[*a].date.cmp(&charges[*b].date));

    let mut used: Vec<((i64, i64), i64)> = Vec::new();
    let mut charged_minor = vec![0i64; charges.len()];
    for index in order {
        let slot = match used.iter().position(|(k, _)| *k == keys[index]) {
            Some(p) => p,
            None => {
                used.push((keys[index], 0));
                used.len() - 1
            }
        };
        let spent = used[slot].1;
        let take = charges[index].amount.minor.min(monthly_cap.minor - spent);
        charged_minor[index] = take;
        used[slot].1 = spent + take;
    }

    let mut out = Vec::new();
    let mut total_charged = 0i64;
    let mut total_waived = 0i64;
    for (index, charge) in charges.iter().enumerate() {
        let charged = charged_minor[index];
        let waived = charge.amount.minor - charged;
        total_charged += charged;
        total_waived += waived;
        out.push(CappedCharge {
            date: charge.date.clone(),
            label: charge.label.clone(),
            amount: money(charge.amount.minor, currency),
            charged: money(charged, currency),
            waived: money(waived, currency),
        });
    }
    FeeCapResult {
        charges: out,
        total_charged: money(total_charged, currency),
        total_waived: money(total_waived, currency),
    }
}

pub fn fee_charge_from_value(v: &Value) -> FeeCharge {
    FeeCharge {
        date: v.get("date").as_str().to_string(),
        label: v.get("label").as_str().to_string(),
        amount: money_from_value(v.get("amount")),
    }
}

pub fn fee_cap_result_to_value(result: &FeeCapResult) -> Value {
    Value::obj(vec![
        (
            "charges",
            Value::Arr(
                result
                    .charges
                    .iter()
                    .map(|c| {
                        Value::obj(vec![
                            ("date", Value::str(&c.date)),
                            ("label", Value::str(&c.label)),
                            ("amount", money_to_value(&c.amount)),
                            ("charged", money_to_value(&c.charged)),
                            ("waived", money_to_value(&c.waived)),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("totalCharged", money_to_value(&result.total_charged)),
        ("totalWaived", money_to_value(&result.total_waived)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let charges: Vec<FeeCharge> = args[0].as_arr().iter().map(fee_charge_from_value).collect();
    fee_cap_result_to_value(&apply_fee_cap(&charges, &money_from_value(&args[1]), args[2].as_i64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 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.fees-cap
Download for Rust banking.fees-cap-1.0.0-rust.fune · 34,443 bytes sha256 8ef94aa9d0287fa76352641bd81ad7e6c22f0b93285723407241aadf9714cad3

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

The whole function, every language, is one file too: banking.fees-cap-1.0.0.fune, 40,737 bytes, sha256 c45e5342578439b5b049d92f612659f611e4531086b25829c7475f30eeceacc2. 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.fees-cap

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

// fune: after banking.fees-cap

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 banking.fees-cap
// fune: replace money.amount in banking.fees-cap

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.fees-cap --steps.

// fune: step banking.fees-cap 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
under the cap: everything is charged charges ×2, £20.00, 1 → charges ×2, total charged £10.00, total waived £0.00
exactly at the cap: nothing is waived charges ×2, £20.00, 1 → charges ×2, total charged £20.00, total waived £0.00
the charge that crosses the cap is reduced, later ones waived charges ×4, £20.00, 1 → charges ×4, total charged £20.00, total waived £12.00
a new calendar month starts a fresh cap charges ×3, £20.00, 1 → charges ×3, total charged £35.00, total waived £10.00
dates out of order are capped in date order but returned in input order charges ×2, £20.00, 1 → charges ×2, total charged £20.00, total waived £10.00
same-day charges keep their input order charges ×2, £20.00, 1 → charges ×2, total charged £20.00, total waived £10.00
cycle day 15: the 14th belongs to the previous period charges ×3, £20.00, 15 → charges ×3, total charged £35.00, total waived £10.00
cycle day 15 across a year end charges ×3, £20.00, 15 → charges ×3, total charged £35.00, total waived £10.00
a zero cap waives everything charges ×2, £0.00, 1 → charges ×2, total charged £0.00, total waived £12.00
zero charges are allowed and change nothing charges ×2, £20.00, 1 → charges ×2, total charged £20.00, total waived £5.00
Show the other 8 tests
CaseArgumentsExpected
no charges at all , £20.00, 1 → charges , total charged £0.00, total waived £0.00
a leap day is an ordinary date charges ×2, £20.00, 1 → charges ×2, total charged £20.00, total waived £4.00
a negative charge is an error charges ×1, £20.00, 1 → error: must not be negative
a charge in another currency is an error charges ×1, £20.00, 1 → error: currency mismatch: EUR and GBP
cycle day 29 is refused , £20.00, 29 → error: cycleDay must be 1 to 28
cycle day 0 is refused , £20.00, 0 → error: cycleDay must be 1 to 28
a negative cap is an error , -£0.01, 1 → error: monthlyCap must not be negative
an impossible date is an error charges ×1, £20.00, 1 → error: not a real calendar date

More from the author

The motivating rule is the unarranged overdraft monthly maximum charge: under the Retail Banking Market Investigation Order 2017 (Competition and Markets Authority, https://www.gov.uk/government/publications/retail-banking-market-investigation-order-2017) each provider sets and publishes a monthly cap on its unarranged overdraft charges, and must not charge more in a month. The same arithmetic serves any "no more than X a month" fee promise. **The cap is the caller's figure**: it is each bank's own published number, not a regulatory constant, so it is an argument rather than data here.

## Charging periods

A charging period starts on `cycleDay` of each month and runs to the day before the same day of the next month. `cycleDay` 1 is the calendar month; statement cycles often start on another day, so 1 to 28 is accepted (29-31 do not exist in every month, so they are refused rather than guessed at). With `cycleDay` 15, a charge on 14 March belongs to the period that began on 15 February.

## Order

Charges are capped in date order, and charges on the same date in the order given, because the cap is reached by whichever charge was raised first. The result is in input order so it lines up with the caller's list.

## Edge cases

- Zero charges are allowed; negative charges (refunds) are an error, because a refund is not a charge and should not free up room under the cap. - A zero cap waives everything. - Every charge must be in the cap's currency. - An empty list gives empty totals in the cap's currency.

Files

PathBytes
README.md1,941
impl/python.py2,981
impl/rust.rs4,720
impl/typescript.ts3,094
vectors.json21,471