Functional Weave
Code in Python

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.

def apply_fee_cap(charges: Sequence[FeeCharge], monthly_cap: Money, cycle_day: int) -> 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

@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
impl/python.py · 75 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.

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
Download for Python banking.fees-cap-1.0.0-python.fune · 32,643 bytes sha256 1aad953a020c856b7a10497ef70c38802234d84d0e1832c3d4ecb8bb4523384c

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.

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