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.
def wip_valuation(entries: Sequence[WipEntry], basis: WipBasis, write_down_basis_points: int, 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 = Literal["cost", "charge-out"]
@dataclass(frozen=True)
class WipEntry:
"""One unbilled time entry."""
#: time recorded, 0 or more
minutes: int
#: hourly cost of the fee earner (salary and overhead)
cost_rate: Money
#: hourly charge-out rate
charge_rate: Money
@dataclass(frozen=True)
class WipValuation:
"""The value of the work in progress."""
#: total unbilled time
minutes: int
#: each entry's minutes at the hourly rate, rounded half-up per entry, then added
gross: Money
#: gross at the write-down rate, rounded half-up
write_down: Money
#: gross less the write-down: the carrying value
net: Money
Your code names it in one line, in the file that uses it
from fune.professional.wip_valuation import wip_valuation # professional.wip-valuation@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Sequence
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate ← from money.apply-rate ^1.0.0 · built alongside by fune
from .professional_wip_valuation_types import WipBasis, WipEntry, WipValuation
def wip_valuation(entries: Sequence[WipEntry], basis: WipBasis, write_down_basis_points: int, currency: str) -> WipValuation:
"""Work in progress at cost or charge-out rates, less a write-down."""
if basis not in ("cost", "charge-out"):
raise ValueError('unknown WIP basis "%s": expected cost or charge-out' % (basis,))
if (
isinstance(write_down_basis_points, bool)
or not isinstance(write_down_basis_points, int)
or write_down_basis_points < 0
or write_down_basis_points > 10000
):
raise ValueError("writeDownBasisPoints must be between 0 and 10000, received %s" % (write_down_basis_points,))
minutes = 0
gross = 0
for entry in entries:
if isinstance(entry.minutes, bool) or not isinstance(entry.minutes, int) or entry.minutes < 0:
raise ValueError("minutes must be a non-negative integer, received %s" % (entry.minutes,))
rate: Money = entry.cost_rate if basis == "cost" else entry.charge_rate
if rate.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (currency, rate.currency))
if rate.minor < 0:
raise ValueError("hourly rates must not be negative, received %s" % (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")
gross_money = money(gross, currency)
write_down = apply_rate(gross_money, write_down_basis_points, "half-up")
return WipValuation(minutes=minutes, gross=gross_money, write_down=write_down, net=money(gross - write_down.minor, currency))Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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. 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 Python implementation. Install it without the registry with fune add ./professional.wip-valuation-1.0.0-python.fune, or fetch it from a terminal with fune pull professional.wip-valuation@1.0.0:python.
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 |