Functional Weave
Code in TypeScript

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

  • wipValuation(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
  • wipValuation(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
  • wipValuation(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.

export function wipValuation(entries: readonly WipEntry[], basis: WipBasis, writeDownBasisPoints: number, currency: string): 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
writeDownBasisPointsintthe 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

export type WipBasis = "cost" | "charge-out";

/** One unbilled time entry. */
export interface WipEntry {
  /** time recorded, 0 or more */
  readonly minutes: number;
  /** hourly cost of the fee earner (salary and overhead) */
  readonly costRate: Money;
  /** hourly charge-out rate */
  readonly chargeRate: Money;
}

/** The value of the work in progress. */
export interface WipValuation {
  /** total unbilled time */
  readonly minutes: number;
  /** each entry's minutes at the hourly rate, rounded half-up per entry, then added */
  readonly gross: Money;
  /** gross at the write-down rate, rounded half-up */
  readonly writeDown: Money;
  /** gross less the write-down: the carrying value */
  readonly net: Money;
}

Your code names it in one line, in the file that uses it

import { wipValuation } from "#fune/professional.wip-valuation@^1";
impl/typescript.ts · 34 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.

import { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts";  ← from money.apply-rate ^1.0.0 · built alongside by fune
import { type WipBasis, type WipEntry, type WipValuation } from "./professional_wip_valuation_types.ts";

/** Work in progress at cost or charge-out rates, less a write-down. */
export function wipValuation(entries: readonly WipEntry[], basis: WipBasis, writeDownBasisPoints: number, currency: string): WipValuation {
  if (basis !== "cost" && basis !== "charge-out") {
    throw new RangeError(`unknown WIP basis "${basis}": expected cost or charge-out`);
  }
  if (!Number.isInteger(writeDownBasisPoints) || writeDownBasisPoints < 0 || writeDownBasisPoints > 10000) {
    throw new RangeError(`writeDownBasisPoints must be between 0 and 10000, received ${writeDownBasisPoints}`);
  }
  let minutes = 0;
  let gross = 0;
  for (const entry of entries) {
    if (!Number.isInteger(entry.minutes) || entry.minutes < 0) {
      throw new RangeError(`minutes must be a non-negative integer, received ${entry.minutes}`);
    }
    const rate: Money = basis === "cost" ? entry.costRate : entry.chargeRate;
    if (rate.currency !== currency) {
      throw new RangeError(`currency mismatch: ${currency} and ${rate.currency}`);
    }
    if (rate.minor < 0) {
      throw new RangeError(`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 += roundDiv(entry.minutes * rate.minor, 60, "half-up");
  }
  const grossMoney = money(gross, currency);
  const writeDown = applyRate(grossMoney, writeDownBasisPoints, "half-up");
  return { minutes, gross: grossMoney, writeDown, net: money(gross - writeDown.minor, currency) };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript professional.wip-valuation-1.0.0-typescript.fune · 15,875 bytes sha256 4437b446f62b5b8039b7afda390cfd19609394a1ca840c5143cedaabc44f8323

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./professional.wip-valuation-1.0.0-typescript.fune, or fetch it from a terminal with fune pull professional.wip-valuation@1.0.0:typescript.

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