professional.wip-valuation
Value unbilled time (work in progress) at cost or charge-out rates, less a write-down percentage.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 14 tests, run in TypeScript, Python and Rust.
What it does
The value of a firm's unbilled time, its work in progress (WIP), from time entries with an hourly cost and an hourly charge-out rate each.
- `cost` values the time at what it cost the firm (the fee earner's cost rate), the prudent figure for a balance sheet. - `charge-out` values it at what the client would be billed at standard rates, the figure for lock-up and billing forecasts.
For example
wip_valuation(entries ×2, charge-out, 10%, GBP)→ minutes 135, gross £412.50, write down £41.25, net £371.25 charge-out value of two entries with a 10% write-downwip_valuation(entries ×2, cost, 0%, GBP)→ minutes 135, gross £120.00, write down £0.00, net £120.00 the same time at cost, no write-downwip_valuation(entries ×2, charge-out, 0%, GBP)→ minutes 14, gross £23.34, write down £0.00, net £23.34 rounded per entry: two 7-minute entries at 100.00 an hour are 23.34, not 23.33
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 wip_valuation(entries: &[WipEntry], basis: &str, write_down_basis_points: i64, currency: &str) -> WipValuation
| entries | WipEntry[] | unbilled time entries; [] for none |
| basis | WipBasis | cost values time at what it costs the firm, charge-out at what the client would be billed |
| write_down_basis_points | int | the expected write-down, 0 to 10000; 1500 = 15% |
| currency | string | the currency of the result, so an empty list still has one; every rate must be in it |
| returns | WipValuation |
The types it declares, generated into your project
// WipBasis is a string in Rust, one of: "cost", "charge-out".
// Parameters take it as &str and results hold it as String.
/// One unbilled time entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipEntry {
/// time recorded, 0 or more
pub minutes: i64,
/// hourly cost of the fee earner (salary and overhead)
pub cost_rate: Money,
/// hourly charge-out rate
pub charge_rate: Money,
}
/// The value of the work in progress.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipValuation {
/// total unbilled time
pub minutes: i64,
/// each entry's minutes at the hourly rate, rounded half-up per entry, then added
pub gross: Money,
/// gross at the write-down rate, rounded half-up
pub write_down: Money,
/// gross less the write-down: the carrying value
pub net: Money,
}
Your code names it in one line, in the file that uses it
fune!(professional.wip-valuation@^1); // then call wip_valuation(…)
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::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate; ← from money.apply-rate ^1.0.0 · built alongside by fune
/// Work in progress at cost or charge-out rates, less a write-down.
///
/// # Panics
/// Panics on an unknown basis, a write-down outside 0..=10000, negative
/// minutes or rates, or a rate in another currency.
pub fn wip_valuation(entries: &[WipEntry], basis: &str, write_down_basis_points: i64, currency: &str) -> WipValuation {
if basis != "cost" && basis != "charge-out" {
panic!("unknown WIP basis \"{}\": expected cost or charge-out", basis);
}
if !(0..=10000).contains(&write_down_basis_points) {
panic!("writeDownBasisPoints must be between 0 and 10000, received {}", write_down_basis_points);
}
let mut minutes = 0;
let mut gross = 0;
for entry in entries {
if entry.minutes < 0 {
panic!("minutes must be a non-negative integer, received {}", entry.minutes);
}
let rate = if basis == "cost" { &entry.cost_rate } else { &entry.charge_rate };
if rate.currency != currency {
panic!("currency mismatch: {} and {}", currency, rate.currency);
}
if rate.minor < 0 {
panic!("hourly rates must not be negative, received {}", rate.minor);
}
minutes += entry.minutes;
// Per entry, as the time would be billed, so WIP reconciles with the bill.
gross += round_div(entry.minutes * rate.minor, 60, "half-up");
}
let gross_money = money(gross, currency);
let write_down = apply_rate(&gross_money, write_down_basis_points, "half-up");
let net = money(gross - write_down.minor, currency);
WipValuation { minutes, gross: gross_money, write_down, net }
}
pub fn wip_entry_from_value(v: &Value) -> WipEntry {
WipEntry {
minutes: v.get("minutes").as_i64(),
cost_rate: money_from_value(v.get("costRate")),
charge_rate: money_from_value(v.get("chargeRate")),
}
}
pub fn wip_valuation_to_value(w: &WipValuation) -> Value {
Value::obj(vec![
("minutes", Value::Int(w.minutes)),
("gross", money_to_value(&w.gross)),
("writeDown", money_to_value(&w.write_down)),
("net", money_to_value(&w.net)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let entries: Vec<WipEntry> = args[0].as_arr().iter().map(wip_entry_from_value).collect();
wip_valuation_to_value(&wip_valuation(&entries, args[1].as_str(), args[2].as_i64(), args[3].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 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 professional.wip-valuation
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./professional.wip-valuation-1.0.0-rust.fune, or fetch it from a terminal with fune pull professional.wip-valuation@1.0.0:rust.
The whole function, every language, is one file too: professional.wip-valuation-1.0.0.fune, 20,532 bytes, sha256 85ce0dffecec883e8332a37bbc0897c3a983c6bc2e3f45e45df36fa9275d365c. 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 professional.wip-valuation
after — your function gets the result and the arguments, and returns the final result.
// fune: after professional.wip-valuation
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 math.round-div in professional.wip-valuation
// fune: replace money.amount in professional.wip-valuation
// fune: replace money.apply-rate in professional.wip-valuation
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 professional.wip-valuation --steps.
// fune: step professional.wip-valuation 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 | |
|---|---|---|---|
| charge-out value of two entries with a 10% write-down | entries ×2, charge-out, 10%, GBP | → | minutes 135, gross £412.50, write down £41.25, net £371.25 |
| the same time at cost, no write-down | entries ×2, cost, 0%, GBP | → | minutes 135, gross £120.00, write down £0.00, net £120.00 |
| rounded per entry: two 7-minute entries at 100.00 an hour are 23.34, not 23.33 | entries ×2, charge-out, 0%, GBP | → | minutes 14, gross £23.34, write down £0.00, net £23.34 |
| a write-down landing on half a penny rounds up | entries ×1, cost, 50%, GBP | → | minutes 60, gross £123.45, write down £61.73, net £61.72 |
| a 100% write-down leaves nothing | entries ×2, charge-out, 100%, GBP | → | minutes 135, gross £412.50, write down £412.50, net £0.00 |
| no unbilled time values at zero in the named currency | , charge-out, 15%, EUR | → | minutes 0, gross €0.00, write down €0.00, net €0.00 |
| a zero-minute entry adds nothing | entries ×2, charge-out, 0%, GBP | → | minutes 30, gross £100.00, write down £0.00, net £100.00 |
| an entry at a zero cost rate (a trainee's pro bono time) is zero at cost | entries ×1, cost, 0%, GBP | → | minutes 120, gross £0.00, write down £0.00, net £0.00 |
| in US dollars, 1 minute at 99.99 an hour rounds half-up to 1.67 | entries ×1, charge-out, 0%, USD | → | minutes 1, gross $1.67, write down $0.00, net $1.67 |
| a write-down over 100% is an error | entries ×2, cost, 100.01%, GBP | → | error: writeDownBasisPoints must be between 0 and 10000 |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a negative write-down is an error | entries ×2, cost, -0.01%, GBP | → | error: writeDownBasisPoints must be between 0 and 10000 |
| negative minutes are an error | entries ×1, cost, 0%, GBP | → | error: minutes must be a non-negative integer |
| a rate in another currency is an error | entries ×2, cost, 0%, EUR | → | error: currency mismatch |
| an unknown basis is an error | entries ×2, market, 0%, GBP | → | error: unknown WIP basis |
More from the author
Each entry is valued as minutes x hourly rate / 60, rounded half-up to the minor unit **per entry**, and the entries are added. That is how the same time would appear on a bill, so the WIP figure reconciles with the bill it becomes; rounding only the total gives a different answer (two 7-minute entries at GBP 100 an hour are 11.67 each, 23.34, not 23.33).
The write-down (the part of the time the firm does not expect to recover, from experience or a partner's review) is a rate in basis points applied once to the gross, rounded half-up, and the net is gross less write-down, so the three always add up exactly.
Which basis, write-down and accounting treatment are right for a set of accounts (for UK firms, FRS 102 section 23 revenue on service contracts, which often values unbilled time at its recoverable amount) is a matter for the firm's accountants; this does the arithmetic consistently for whichever basis they choose. Rates are hourly and in one currency, named by the `currency` argument so that an empty list values at zero rather than failing.
Files
| Path | Bytes |
|---|---|
| README.md | 1,482 |
| impl/python.py | 1,912 |
| impl/rust.rs | 2,563 |
| impl/typescript.ts | 1,805 |
| vectors.json | 7,873 |