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 helpprorate(£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 sideprorate(£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
| amount | Money | the whole period's charge; negative for a credit note |
| total_days | int | length of the billing period, greater than zero |
| used_days | int | days consumed, from 0 to totalDays inclusive |
| returns | ProrationSplit |
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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,382 |
| impl/python.py | 1,754 |
| impl/rust.rs | 2,735 |
| impl/typescript.ts | 1,673 |
| vectors.json | 3,787 |