Functional Weave
Code in Rust

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.

pub fn prorate(amount: &Money, total_days: i64, used_days: i64) -> 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

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProrationSplit {
    pub total: Money,
    pub used: Money,
    pub unused: Money,
}

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

fune!(finance.proration@^1);  // then call prorate(…)
impl/rust.rs · 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.

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::money_allocate::allocate;  ← from money.allocate ^1.0.0 · built alongside by fune
use super::money_amount::{money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

/// 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.
///
/// # Panics
/// Panics if `total_days` is not positive, or `used_days` is outside
/// `0..=total_days`.
pub fn prorate(amount: &Money, total_days: i64, used_days: i64) -> ProrationSplit {
    if total_days <= 0 {
        panic!(
            "total days must be greater than zero, received {}",
            total_days
        );
    }
    if used_days < 0 {
        panic!("used days must not be negative, received {}", 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.
        panic!(
            "used days must not exceed total days, received {} of {}",
            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.
    let parts = allocate(amount, &[used_days, total_days - used_days]);

    ProrationSplit {
        total: amount.clone(),
        used: parts[0].clone(),
        unused: parts[1].clone(),
    }
}

pub fn proration_split_to_value(split: &ProrationSplit) -> Value {
    Value::obj(vec![
        ("total", money_to_value(&split.total)),
        ("used", money_to_value(&split.used)),
        ("unused", money_to_value(&split.unused)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold, with the wording TypeScript
    // and Python use, rather than let the conversion below quietly change it.
    if let Value::Float(f) = args[1] {
        if f.fract() != 0.0 {
            panic!("total days must be greater than zero, received {}", f);
        }
    }
    if let Value::Float(f) = args[2] {
        if f.fract() != 0.0 {
            panic!("used days must not be negative, received {}", f);
        }
    }
    proration_split_to_value(&prorate(
        &money_from_value(&args[0]),
        args[1].as_i64(),
        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 finance.proration
Download for Rust finance.proration-1.0.0-rust.fune · 10,432 bytes sha256 62f2a9d78a3a7016dd286335e456722571fac6d4624bfc33d53ef3075c720bc2

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

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