Functional Weave
Code in Python

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.

def wip_valuation(entries: Sequence[WipEntry], basis: WipBasis, write_down_basis_points: int, 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 = 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
impl/python.py · 35 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.

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
Download for Python professional.wip-valuation-1.0.0-python.fune · 15,981 bytes sha256 c54d81d143a73bb5b0b4e3f58ecbec1bce9fe8aadc1d8f0f96081b59823f01e8

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.

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