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 chargedapply_fee_cap(charges ×2, £20.00, 1)→ charges ×2, total charged £20.00, total waived £0.00 exactly at the cap: nothing is waivedapply_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.
def apply_fee_cap(charges: Sequence[FeeCharge], monthly_cap: Money, cycle_day: int) -> FeeCapResult
| charges | FeeCharge[] | every charge raised, in any order; each is capped within its own charging period |
| monthly_cap | Money | the most that may be charged in one charging period; the bank's own figure |
| cycle_day | 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
@dataclass(frozen=True)
class FeeCharge:
"""One charge as raised, before any cap."""
date: str
#: e.g. "Unarranged overdraft fee"
label: str
#: 0 or more
amount: Money
@dataclass(frozen=True)
class CappedCharge:
"""One charge after the cap: charged plus waived is the amount raised."""
date: str
label: str
amount: Money
charged: Money
waived: Money
@dataclass(frozen=True)
class FeeCapResult:
"""The capped charges and their totals."""
charges: List[CappedCharge]
total_charged: Money
total_waived: Money
Your code names it in one line, in the file that uses it
from fune.banking.fees_cap import apply_fee_cap # 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.
from typing import Dict, List, Sequence
from .banking_fees_cap_types import CappedCharge, FeeCapResult, FeeCharge
from .dates_add_days import parse_iso_date ← from dates.add-days ^1.0.0 · built alongside by fune
from .money_amount import Money, money ← from money.amount ^1.0.0 · built alongside by fune
def _period_key(iso: str, cycle_day: int) -> str:
"""The charging period a date falls in, named by the year and month it
starts in. With cycle_day 15, 2026-03-14 belongs to the period that began
on 2026-02-15."""
d = parse_iso_date(iso)
year, month = d.year, d.month
if d.day < cycle_day:
month -= 1
if month == 0:
month = 12
year -= 1
return "%d-%d" % (year, month)
def apply_fee_cap(charges: Sequence[FeeCharge], monthly_cap: Money, cycle_day: int) -> FeeCapResult:
"""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.
"""
if isinstance(cycle_day, bool) or not isinstance(cycle_day, int) or cycle_day < 1 or cycle_day > 28:
raise ValueError("cycleDay must be 1 to 28, received %s" % (cycle_day,))
if monthly_cap.minor < 0:
raise ValueError("monthlyCap must not be negative, received %d" % (monthly_cap.minor,))
currency = monthly_cap.currency
keys: List[str] = []
for charge in charges:
if charge.amount.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (charge.amount.currency, currency))
if charge.amount.minor < 0:
raise ValueError('charge "%s" must not be negative, received %d' % (charge.label, charge.amount.minor))
keys.append(_period_key(charge.date, cycle_day))
# sorted() is stable, so equal dates keep their input order.
order = sorted(range(len(charges)), key=lambda i: charges[i].date)
used: Dict[str, int] = {}
charged_minor = [0] * len(charges)
for index in order:
spent = used.get(keys[index], 0)
take = min(charges[index].amount.minor, monthly_cap.minor - spent)
charged_minor[index] = take
used[keys[index]] = spent + take
out: List[CappedCharge] = []
total_charged = 0
total_waived = 0
for index, charge in enumerate(charges):
charged = charged_minor[index]
waived = charge.amount.minor - charged
total_charged += charged
total_waived += waived
out.append(
CappedCharge(
date=charge.date,
label=charge.label,
amount=money(charge.amount.minor, currency),
charged=money(charged, currency),
waived=money(waived, currency),
)
)
return FeeCapResult(
charges=out,
total_charged=money(total_charged, currency),
total_waived=money(total_waived, currency),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./banking.fees-cap-1.0.0-python.fune, or fetch it from a terminal with fune pull banking.fees-cap@1.0.0:python.
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 |