Functional Weave
Code in Rust

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-down
  • wip_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-down
  • wip_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
entriesWipEntry[]unbilled time entries; [] for none
basisWipBasiscost values time at what it costs the firm, charge-out at what the client would be billed
write_down_basis_pointsintthe expected write-down, 0 to 10000; 1500 = 15%
currencystringthe currency of the result, so an empty list still has one; every rate must be in it
returnsWipValuation

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(…)
impl/rust.rs · 61 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::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
Download for Rust professional.wip-valuation-1.0.0-rust.fune · 16,666 bytes sha256 6881765a7603a4ac84c5beecec42bc4d00f8956dbfc7a936b98871ab9b39144c

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,482
impl/python.py1,912
impl/rust.rs2,563
impl/typescript.ts1,805
vectors.json7,873