Functional Weave
Code in TypeScript

hospitality.service-charge

A discretionary service charge on a bill's eligible lines, as a basis-point rate with explicit rounding.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 17 tests, run in TypeScript, Python and Rust.

What it does

Works out a discretionary service charge on the lines of a bill that it applies to: 12.5% of the food, say, and not the drinks bought at the bar. It returns the eligible total, the charge, the bill before the charge and the bill with it.

## Why it is shaped this way

For example

  • serviceCharge(lines ×4, 12.5%, half-up) → eligible total £71.95, rate 12.5%, charge £8.99, subtotal £71.95, total £80.94 12.5% on a whole bill: 71.95 gives 8.99375, rounded half-up to 8.99
  • serviceCharge(lines ×2, 10%, half-up) → eligible total £40.00, rate 10%, charge £4.00, subtotal £65.00, total £69.00 only the food is eligible
  • serviceCharge(lines ×3, 12.5%, half-up) → eligible total £9.99, rate 12.5%, charge £1.25, subtotal £9.99, total £11.24 charged once on the total, not per line: 12.5% of 9.99 is 1.25, not 3 x 0.42

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 serviceCharge(lines: readonly BillLine[], basisPoints: number, mode: RoundingMode): ServiceCharge
linesBillLine[]the bill as the customer sees it, at least one line, one currency
basisPointsint1250 = 12.5%; 0 to 10000
modeRoundingModehow the one rounding step rounds, usually half-up
returnsServiceCharge

The types it declares, generated into your project

/** One line of a bill. */
export interface BillLine {
  readonly description: string;
  /** negative for a discount line */
  readonly amount: Money;
  /** false for lines the charge does not apply to */
  readonly eligible: boolean;
}

/** The charge and the totals around it. */
export interface ServiceCharge {
  /** the lines the charge is worked out on */
  readonly eligibleTotal: Money;
  readonly basisPoints: number;
  readonly charge: Money;
  /** every line, before the charge */
  readonly subtotal: Money;
  /** subtotal plus the charge */
  readonly total: Money;
}

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

import { serviceCharge } from "#fune/hospitality.service-charge@^1";
impl/typescript.ts · 36 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 { type RoundingMode } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { addMoney } from "./money_add.ts";  ← from money.add ^1.0.0 · built alongside by fune
import { type 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 { sumMoney } from "./money_sum.ts";  ← from money.sum ^1.0.0 · built alongside by fune
import { type BillLine, type ServiceCharge } from "./hospitality_service_charge_types.ts";

/**
 * A discretionary service charge on the eligible lines of a bill.
 *
 * The rate is applied once, to the eligible total, and rounded once. Charging
 * each line and adding the pennies up drifts: three 3.33 lines at 12.5% are
 * 0.42 each, 1.26 in all, where 12.5% of 9.99 is 1.25.
 */
export function serviceCharge(lines: readonly BillLine[], basisPoints: number, mode: RoundingMode): ServiceCharge {
  if (lines.length === 0) {
    throw new RangeError("a bill needs at least one line");
  }
  if (!Number.isInteger(basisPoints) || basisPoints < 0 || basisPoints > 10000) {
    throw new RangeError(`basisPoints must be a whole number from 0 to 10000, received ${basisPoints}`);
  }
  const currency = lines[0].amount.currency;
  const subtotal = sumMoney(
    lines.map((line) => line.amount),
    currency,
  );
  const eligibleTotal: Money = sumMoney(
    lines.filter((line) => line.eligible).map((line) => line.amount),
    currency,
  );
  if (eligibleTotal.minor < 0) {
    throw new RangeError(`the eligible lines total ${eligibleTotal.minor}, and a service charge cannot be negative`);
  }
  const charge = applyRate(eligibleTotal, basisPoints, mode);
  return { eligibleTotal, basisPoints, charge, subtotal, total: addMoney(subtotal, charge) };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 5 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 hospitality.service-charge
Download for TypeScript hospitality.service-charge-1.0.0-typescript.fune · 14,690 bytes sha256 d46d3cf621924825f07bf0a67891d8028b106bd39ef6d9b9a9a30d06419827cf

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

The whole function, every language, is one file too: hospitality.service-charge-1.0.0.fune, 18,941 bytes, sha256 2a0ae152d790183164c02f7b44a848c2d7815696806025aca498132d1d764c3c. 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 hospitality.service-charge

after — your function gets the result and the arguments, and returns the final result.

// fune: after hospitality.service-charge

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 hospitality.service-charge
// fune: replace money.add in hospitality.service-charge
// fune: replace money.amount in hospitality.service-charge
// fune: replace money.apply-rate in hospitality.service-charge
// fune: replace money.sum in hospitality.service-charge

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 hospitality.service-charge --steps.

// fune: step hospitality.service-charge 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
12.5% on a whole bill: 71.95 gives 8.99375, rounded half-up to 8.99 lines ×4, 12.5%, half-up → eligible total £71.95, rate 12.5%, charge £8.99, subtotal £71.95, total £80.94
only the food is eligible lines ×2, 10%, half-up → eligible total £40.00, rate 10%, charge £4.00, subtotal £65.00, total £69.00
charged once on the total, not per line: 12.5% of 9.99 is 1.25, not 3 x 0.42 lines ×3, 12.5%, half-up → eligible total £9.99, rate 12.5%, charge £1.25, subtotal £9.99, total £11.24
rounding up lines ×1, 12.5%, up → eligible total £9.99, rate 12.5%, charge £1.25, subtotal £9.99, total £11.24
rounding down lines ×1, 12.5%, down → eligible total £9.99, rate 12.5%, charge £1.24, subtotal £9.99, total £11.23
an exact half rounds up with half-up: 12.5% of 10.12 is 1.265 lines ×1, 12.5%, half-up → eligible total £10.12, rate 12.5%, charge £1.27, subtotal £10.12, total £11.39
an exact half rounds to even with half-even lines ×1, 12.5%, half-even → eligible total £10.12, rate 12.5%, charge £1.26, subtotal £10.12, total £11.38
a discount line reduces the eligible total lines ×2, 12.5%, half-up → eligible total £25.00, rate 12.5%, charge £3.13, subtotal £25.00, total £28.13
a zero rate is no charge lines ×4, 0%, half-up → eligible total £71.95, rate 0%, charge £0.00, subtotal £71.95, total £71.95
no eligible lines is no charge lines ×1, 12.5%, half-up → eligible total £0.00, rate 12.5%, charge £0.00, subtotal £25.00, total £25.00
Show the other 7 tests
CaseArgumentsExpected
the whole amount at 100% lines ×1, 100%, half-up → eligible total £10.00, rate 100%, charge £10.00, subtotal £10.00, total £20.00
euro bill lines ×1, 10%, half-up → eligible total €45.90, rate 10%, charge €4.59, subtotal €45.90, total €50.49
an empty bill is an error , 12.5%, half-up → error: a bill needs at least one line
a negative rate is an error lines ×4, -0.01%, half-up → error: basisPoints must be a whole number from 0 to 10000
a rate over 100% is an error lines ×4, 100.01%, half-up → error: basisPoints must be a whole number from 0 to 10000
mixed currencies are an error lines ×2, 12.5%, half-up → error: currency mismatch
a negative eligible total is an error lines ×1, 12.5%, half-up → error: a service charge cannot be negative

More from the author

- **One rounding step.** The rate is applied once, to the eligible total, and rounded once, in the mode the caller chooses. Charging each line and adding the pennies drifts: three 3.33 lines at 12.5% are 0.42 each (1.26), but 12.5% of 9.99 is 1.25. - **Lines say whether they are eligible.** Venues leave out different things (bar drinks, a corkage fee, a cake brought in), so the function does not guess. A discount line (negative amount) that is eligible lowers the base. - **The rate is a basis-point integer**: 1250 is 12.5%, anything from 0 to 10000.

## Edge cases

- No eligible lines, or a 0 rate, means a charge of 0. - An eligible total below zero (a refund bill) is an error, not a negative charge. - Every line must be in the same currency.

## Not covered

- **VAT.** HMRC treats a genuinely optional service charge as outside the scope of VAT, and a compulsory one as part of the price of the meal, taxed at the meal's rate (VAT Notice 709/1, *Catering and takeaway food*, section on tips and service charges: https://www.gov.uk/guidance/catering-and-take-away-food-vat-notice-7091). This function works out the amount only. Whether the charge is really optional is a fact about how the venue sells, not something it can see. - **Who gets the money.** Under the Employment (Allocation of Tips) Act 2023, service charges an employer controls must be passed to workers in full and shared fairly; see `hospitality.tronc-allocation`.

Files

PathBytes
README.md1,771
impl/python.py1,596
impl/rust.rs2,499
impl/typescript.ts1,597
vectors.json7,226