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
applyFeeCap(charges ×2, £20.00, 1)→ charges ×2, total charged £10.00, total waived £0.00 under the cap: everything is chargedapplyFeeCap(charges ×2, £20.00, 1)→ charges ×2, total charged £20.00, total waived £0.00 exactly at the cap: nothing is waivedapplyFeeCap(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.
export function applyFeeCap(charges: readonly FeeCharge[], monthlyCap: Money, cycleDay: number): FeeCapResult
| charges | FeeCharge[] | every charge raised, in any order; each is capped within its own charging period |
| monthlyCap | Money | the most that may be charged in one charging period; the bank's own figure |
| cycleDay | int | day of the month each charging period starts, 1 to 28; 1 is the calendar month |
| returns | FeeCapResult | the 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. */
export interface FeeCharge {
readonly date: string;
/** e.g. "Unarranged overdraft fee" */
readonly label: string;
/** 0 or more */
readonly amount: Money;
}
/** One charge after the cap: charged plus waived is the amount raised. */
export interface CappedCharge {
readonly date: string;
readonly label: string;
readonly amount: Money;
readonly charged: Money;
readonly waived: Money;
}
/** The capped charges and their totals. */
export interface FeeCapResult {
readonly charges: readonly CappedCharge[];
readonly totalCharged: Money;
readonly totalWaived: Money;
}
Your code names it in one line, in the file that uses it
import { applyFeeCap } from "#fune/banking.fees-cap@^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 { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type CappedCharge, type FeeCapResult, type FeeCharge } from "./banking_fees_cap_types.ts";
/**
* The charging period a date falls in, named by the year and month it starts
* in. With cycleDay 15, 2026-03-14 belongs to the period that began on
* 2026-02-15 and 2026-03-15 to the one that begins that day.
*/
function periodKey(iso: string, cycleDay: number): string {
const d = parseIsoDate(iso);
let year = d.year;
let month = d.month;
if (d.day < cycleDay) {
month -= 1;
if (month === 0) {
month = 12;
year -= 1;
}
}
return `${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, so it lines up with the caller's own list.
*/
export function applyFeeCap(charges: readonly FeeCharge[], monthlyCap: Money, cycleDay: number): FeeCapResult {
if (!Number.isInteger(cycleDay) || cycleDay < 1 || cycleDay > 28) {
throw new RangeError(`cycleDay must be 1 to 28, received ${cycleDay}`);
}
if (monthlyCap.minor < 0) {
throw new RangeError(`monthlyCap must not be negative, received ${monthlyCap.minor}`);
}
const currency = monthlyCap.currency;
const keys: string[] = [];
charges.forEach((charge) => {
if (charge.amount.currency !== currency) {
throw new RangeError(`currency mismatch: ${charge.amount.currency} and ${currency}`);
}
if (charge.amount.minor < 0) {
throw new RangeError(`charge "${charge.label}" must not be negative, received ${charge.amount.minor}`);
}
keys.push(periodKey(charge.date, cycleDay));
});
const order = charges.map((_, index) => index);
// Array.prototype.sort is stable, so equal dates keep their input order.
order.sort((a, b) => (charges[a].date < charges[b].date ? -1 : charges[a].date > charges[b].date ? 1 : 0));
const used = new Map<string, number>();
const chargedMinor: number[] = charges.map(() => 0);
for (const index of order) {
const spent = used.get(keys[index]) ?? 0;
const room = monthlyCap.minor - spent;
const take = Math.min(charges[index].amount.minor, room);
chargedMinor[index] = take;
used.set(keys[index], spent + take);
}
let totalCharged = 0;
let totalWaived = 0;
const out: CappedCharge[] = charges.map((charge, index) => {
const charged = chargedMinor[index];
const waived = charge.amount.minor - charged;
totalCharged += charged;
totalWaived += waived;
return {
date: charge.date,
label: charge.label,
amount: money(charge.amount.minor, currency),
charged: money(charged, currency),
waived: money(waived, currency),
};
});
return { charges: out, totalCharged: money(totalCharged, currency), totalWaived: money(totalWaived, 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 2 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 banking.fees-cap
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./banking.fees-cap-1.0.0-typescript.fune, or fetch it from a terminal with fune pull banking.fees-cap@1.0.0:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,941 |
| impl/python.py | 2,981 |
| impl/rust.rs | 4,720 |
| impl/typescript.ts | 3,094 |
| vectors.json | 21,471 |