finance.accruals-prepayments
Split an invoice across accounting periods by days of service, for accruals, prepayments and deferred income.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
The matching principle, as arithmetic: an invoice for a service that runs over several accounting periods belongs to those periods in proportion to the days of service in each. A year's insurance paid in January is mostly a prepayment at the end of March; a quarter's rent billed in arrears is an accrual until the invoice arrives. This works out each period's share; which of those shares is the prepayment, accrual or deferred income at a balance-sheet date depends on when the invoice was booked, and is the caller's to read off.
Days are calendar days, both ends inclusive, and the split is by money.allocate with each period's days as its weight, so the shares always add back to the invoice to the minor unit. The odd units go to the largest remainders, ties to the earlier period. A year's 1,200.00 over four quarters of 90, 91, 92 and 92 days is 295.89, 299.18, 302.47 and 302.46, not four lots of 300.00 (which is what splitting by months would give).
For example
allocate_to_periods(£1,200.00, 2026-01-01, 2026-12-31, periods ×4)→ ×4 a year's insurance by quarter: by days, not by months, and not a penny lostallocate_to_periods(-£1,200.00, 2026-01-01, 2026-12-31, periods ×4)→ ×4 a credit note mirrors its invoice period by periodallocate_to_periods(£3,100.00, 2026-12-15, 2027-01-14, periods ×2)→ ×2 a month's service across a year end
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 allocate_to_periods(amount: &Money, service_start: &str, service_end: &str, periods: &[AccountingPeriod]) -> Vec<PeriodAllocation>
| amount | Money | the invoice's net amount; negative for a credit note |
| service_start | date | first day of the service the invoice covers |
| service_end | date | last day of the service, inclusive |
| periods | AccountingPeriod[] | the accounting periods, in date order, together covering the whole service |
| returns | PeriodAllocation[] |
The types it declares, generated into your project
/// One accounting period, by its first and last day.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountingPeriod {
/// e.g. "2026-03" or "Q1"
pub label: String,
pub start: String,
/// inclusive
pub end: String,
}
/// The part of the invoice that belongs to one period.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeriodAllocation {
pub label: String,
/// days of service falling in the period
pub days: i64,
pub amount: Money,
}
Your code names it in one line, in the file that uses it
fune!(finance.accruals-prepayments@^1); // then call allocate_to_periods(…)
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::dates_days_between::days_between; ← from dates.days-between ^1.0.0 · built alongside by fune
use super::dates_overlap::overlap_days; ← from dates.overlap ^1.0.0 · built alongside by fune
use super::money_allocate::allocate; ← from money.allocate ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
/// Split an invoice across accounting periods by days of service.
///
/// money.allocate does the split with each period's days as the weight, so the
/// shares add back to the invoice exactly. A negative amount is split as its
/// positive and negated, so a credit note mirrors its invoice period by period.
///
/// # Panics
/// Panics on malformed dates, a service that ends before it starts, periods
/// that overlap, are out of order or leave days of service uncovered.
pub fn allocate_to_periods(
amount: &Money,
service_start: &str,
service_end: &str,
periods: &[AccountingPeriod],
) -> Vec<PeriodAllocation> {
let service_days = days_between(service_start, service_end) + 1;
if service_days < 1 {
panic!("service ends {}, before it starts {}", service_end, service_start);
}
if periods.is_empty() {
panic!("at least one accounting period is needed");
}
let mut days: Vec<i64> = Vec::with_capacity(periods.len());
for (index, period) in periods.iter().enumerate() {
if days_between(&period.start, &period.end) < 0 {
panic!("period {} ends before it starts", period.label);
}
if index > 0 && days_between(&periods[index - 1].end, &period.start) < 1 {
panic!("period {} overlaps or precedes the period before it", period.label);
}
days.push(overlap_days(service_start, service_end, &period.start, &period.end, true));
}
let covered: i64 = days.iter().sum();
if covered != service_days {
panic!(
"the accounting periods cover {} of the {} days of service",
covered, service_days
);
}
let negative = amount.minor < 0;
let magnitude = money(if negative { -amount.minor } else { amount.minor }, &amount.currency);
let shares = allocate(&magnitude, &days);
periods
.iter()
.enumerate()
.map(|(index, period)| PeriodAllocation {
label: period.label.clone(),
days: days[index],
amount: if negative {
money(-shares[index].minor, &amount.currency)
} else {
shares[index].clone()
},
})
.collect()
}
pub fn accounting_period_from_value(v: &Value) -> AccountingPeriod {
AccountingPeriod {
label: v.get("label").as_str().to_string(),
start: v.get("start").as_str().to_string(),
end: v.get("end").as_str().to_string(),
}
}
pub fn period_allocation_to_value(a: &PeriodAllocation) -> Value {
Value::obj(vec![
("label", Value::str(&a.label)),
("days", Value::Int(a.days)),
("amount", money_to_value(&a.amount)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let periods: Vec<AccountingPeriod> = args[3].as_arr().iter().map(accounting_period_from_value).collect();
Value::Arr(
allocate_to_periods(&money_from_value(&args[0]), args[1].as_str(), args[2].as_str(), &periods)
.iter()
.map(period_allocation_to_value)
.collect(),
)
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 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.accruals-prepayments
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./finance.accruals-prepayments-1.0.0-rust.fune, or fetch it from a terminal with fune pull finance.accruals-prepayments@1.0.0:rust.
The whole function, every language, is one file too: finance.accruals-prepayments-1.0.0.fune, 21,194 bytes, sha256 98cd07e7a8663095d0e0f03e602e352103e186343e428b13014ec27501b3bad7. 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.accruals-prepayments
after — your function gets the result and the arguments, and returns the final result.
// fune: after finance.accruals-prepayments
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.days-between in finance.accruals-prepayments
// fune: replace dates.overlap in finance.accruals-prepayments
// fune: replace money.allocate in finance.accruals-prepayments
// fune: replace money.amount in finance.accruals-prepayments
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.accruals-prepayments --steps.
// fune: step finance.accruals-prepayments 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 | |
|---|---|---|---|
| a year's insurance by quarter: by days, not by months, and not a penny lost | £1,200.00, 2026-01-01, 2026-12-31, periods ×4 | → | ×4 |
| a credit note mirrors its invoice period by period | -£1,200.00, 2026-01-01, 2026-12-31, periods ×4 | → | ×4 |
| a month's service across a year end | £3,100.00, 2026-12-15, 2027-01-14, periods ×2 | → | ×2 |
| periods outside the service are listed with nothing in them | £3,100.00, 2026-12-15, 2027-01-14, periods ×4 | → | ×4 |
| leap-year February counts 29 days | £600.00, 2024-02-01, 2024-03-31, periods ×2 | → | ×2 |
| a single day of service | £9.99, 2026-03-31, 2026-03-31, periods ×2 | → | ×2 |
| odd pennies go to the largest remainders | £1.00, 2026-01-01, 2026-01-03, periods ×3 | → | ×3 |
| a 4-4-5 quarter | £13,000.00, 2026-01-04, 2026-04-04, periods ×3 | → | ×3 |
| periods with a gap outside the service are fine | £10.00, 2026-03-10, 2026-03-19, periods ×2 | → | ×2 |
| zero amount | £0.00, 2026-01-01, 2026-12-31, periods ×4 | → | ×4 |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a service that ends before it starts is refused | £1.00, 2026-03-10, 2026-03-09, periods ×4 | → | error: service ends 2026-03-09, before it starts 2026-03-10 |
| no periods is refused | £1.00, 2026-03-10, 2026-03-12, | → | error: at least one accounting period is needed |
| a period that ends before it starts is refused | £1.00, 2026-03-10, 2026-03-12, periods ×1 | → | error: period bad ends before it starts |
| overlapping periods are refused | £1.00, 2026-03-10, 2026-03-12, periods ×2 | → | error: period b overlaps or precedes the period before it |
| periods out of order are refused | £1.00, 2026-03-10, 2026-03-12, periods ×2 | → | error: period Mar overlaps or precedes the period before it |
| days of service outside every period are refused | £1.00, 2026-03-25, 2026-04-05, periods ×1 | → | error: the accounting periods cover 7 of the 12 days of service |
| an impossible date is refused | £1.00, 2026-02-29, 2026-03-05, periods ×4 | → | error: not a real calendar date |
More from the author
A credit note (a negative amount) is split exactly as the invoice it reverses and negated, so the two cancel period by period.
The periods are given, not assumed to be calendar months, because financial years and 4-4-5 calendars do not follow the calendar. They must be in date order, must not overlap, and together must cover every day of service; periods outside the service are allowed and get zero days and a zero amount, so a whole year of periods can be passed for any invoice. Each period appears in the result, in the order given.
Files
| Path | Bytes |
|---|---|
| README.md | 1,535 |
| impl/python.py | 2,069 |
| impl/rust.rs | 3,299 |
| impl/typescript.ts | 2,080 |
| vectors.json | 7,762 |