Functional Weave
Code in Python

finance.proration

Split an amount into the part used and the part unused over a billing period, losing nothing.

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

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

What it does

This is the mid-cycle upgrade, downgrade and cancellation calculation. It is usually written as two independent roundings - round(amount * used / total) for the charge and round(amount * unused / total) for the credit - and those two numbers do not always add back up to the amount. 9.99 over a two-day period splits as 4.995 each way, which two half-up roundings turn into 5.00 and 5.00: a penny invented out of nothing, on a line that a customer can see.

So the split is one call to money.allocate over the ratios [usedDays, unusedDays]. used + unused equals amount exactly, for every input, including negative amounts. The largest-remainder rule gives the odd minor unit to the used side on a tie, which also means the same inputs always split the same way - important when a credit note has to match the invoice it reverses.

For example

  • prorate(£30.00, 30, 10) → total £30.00, used £10.00, unused £20.00 30.00 over 30 days, 10 used: an even split needs no help
  • prorate(£99.00, 31, 10) → total £99.00, used £31.94, unused £67.06 99.00 over a 31 day month, 10 days used: the odd penny goes to the used side
  • prorate(£9.99, 2, 1) → total £9.99, used £5.00, unused £4.99 9.99 over two days: two independent roundings would invent a penny, allocate does not

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 prorate(amount: Money, total_days: int, used_days: int) -> ProrationSplit
amountMoneythe whole period's charge; negative for a credit note
total_daysintlength of the billing period, greater than zero
used_daysintdays consumed, from 0 to totalDays inclusive
returnsProrationSplit

The type it declares, generated into your project

@dataclass(frozen=True)
class ProrationSplit:
    total: Money
    used: Money
    unused: Money

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

from fune.finance.proration import prorate  # finance.proration@^1
impl/python.py · 33 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 .finance_proration_types import ProrationSplit
from .money_allocate import allocate  ← from money.allocate ^1.0.0 · built alongside by fune
from .money_amount import Money  ← from money.amount ^1.0.0 · built alongside by fune


def prorate(amount: Money, total_days: int, used_days: int) -> ProrationSplit:
    """Split a period's charge into the part used and the part not used.

    The mid-cycle upgrade, downgrade and cancellation calculation. Written the
    obvious way - one rounding for the charge and another for the credit - the
    two halves do not reliably add back up: 9.99 over two days is 4.995 each
    way, and two half-up roundings produce 5.00 and 5.00, a penny invented on a
    line the customer can see.

    One call to ``money.allocate`` does the whole split instead, so
    ``used + unused`` is the original amount for every input, credits included.
    """
    if isinstance(total_days, bool) or not isinstance(total_days, int) or total_days <= 0:
        raise ValueError("total days must be greater than zero, received %r" % (total_days,))
    if isinstance(used_days, bool) or not isinstance(used_days, int) or used_days < 0:
        raise ValueError("used days must not be negative, received %r" % (used_days,))
    if used_days > total_days:
        # Clamping here would turn a bug in the caller's period arithmetic into
        # a plausible invoice, which is far more expensive to find later.
        raise ValueError(
            "used days must not exceed total days, received %d of %d" % (used_days, total_days)
        )

    # total_days > 0 guarantees the ratios do not sum to zero, so allocate is
    # safe even when one side is zero: [0, n] and [n, 0] are both well defined.
    used, unused = allocate(amount, [used_days, total_days - used_days])

    return ProrationSplit(total=amount, used=used, unused=unused)

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 finance.proration
Download for Python finance.proration-1.0.0-python.fune · 9,409 bytes sha256 a9cc85ef85b0cc17c7060a92ddb854b8ed12658f245c1576a6ebb5ddd1fdab6c

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./finance.proration-1.0.0-python.fune, or fetch it from a terminal with fune pull finance.proration@1.0.0:python.

The whole function, every language, is one file too: finance.proration-1.0.0.fune, 13,979 bytes, sha256 8787fa0dca9740f81e2faa9aca8f833b8d6ddaa96c980992803b17503cde999b. 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 finance.proration

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

# fune: after finance.proration

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 money.allocate in finance.proration
# fune: replace money.amount in finance.proration

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 finance.proration --steps.

# fune: step finance.proration 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
30.00 over 30 days, 10 used: an even split needs no help £30.00, 30, 10 → total £30.00, used £10.00, unused £20.00
99.00 over a 31 day month, 10 days used: the odd penny goes to the used side £99.00, 31, 10 → total £99.00, used £31.94, unused £67.06
9.99 over two days: two independent roundings would invent a penny, allocate does not £9.99, 2, 1 → total £9.99, used £5.00, unused £4.99
nothing used yet, so the whole charge is still unused £99.00, 31, 0 → total £99.00, used £0.00, unused £99.00
the full period used leaves nothing unused £99.00, 31, 31 → total £99.00, used £99.00, unused £0.00
one day of a 365 day annual plan £10,000.00, 365, 1 → total £10,000.00, used £27.40, unused £9,972.60
a credit note prorates the same way and still sums to the credit -£99.00, 31, 10 → total -£99.00, used -£31.94, unused -£67.06
a free plan splits into nothing and nothing £0.00, 30, 10 → total £0.00, used £0.00, unused £0.00
100.00 over a seven day trial, three days used £100.00, 7, 3 → total £100.00, used £42.86, unused £57.14
an awkward amount over a leap year, mid-period £999.99, 366, 213 → total £999.99, used £581.96, unused £418.03
Show the other 6 tests
CaseArgumentsExpected
yen has no minor unit, so the odd whole yen goes to the larger remainder ¥1,000, 3, 1 → total ¥1,000, used ¥333, unused ¥667
a zero day period is an error, not a division by zero £99.00, 0, 0 → error: must be greater than zero
a negative period is an error £99.00, -31, 0 → error: must be greater than zero
using more days than the period has is an error, not a clamp £99.00, 31, 32 → error: must not exceed total days
negative usage is an error £99.00, 31, -1 → error: must not be negative
a fractional period length is an error: days are whole here £99.00, 30.5, 10 → error: must be greater than zero

More from the author

Both ends are defined rather than special-cased: usedDays of 0 gives the whole amount as unused, usedDays equal to totalDays gives the whole amount as used. totalDays of zero or below, a negative usedDays, and a usedDays past the end of the period are all errors, because each of them means the caller's period arithmetic is wrong and a silently clamped answer would hide it.

Days are the unit here, but nothing in the arithmetic is date-specific: seconds or hours work the same way, as long as both arguments use the same unit.

Files

PathBytes
README.md1,382
impl/python.py1,754
impl/rust.rs2,735
impl/typescript.ts1,673
vectors.json3,787