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
service_charge(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.99service_charge(lines ×2, 10%, half-up)→ eligible total £40.00, rate 10%, charge £4.00, subtotal £65.00, total £69.00 only the food is eligibleservice_charge(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.
pub fn service_charge(lines: &[BillLine], basis_points: i64, mode: &str) -> ServiceCharge
| lines | BillLine[] | the bill as the customer sees it, at least one line, one currency |
| basis_points | int | 1250 = 12.5%; 0 to 10000 |
| mode | RoundingMode | how the one rounding step rounds, usually half-up |
| returns | ServiceCharge |
The types it declares, generated into your project
/// One line of a bill.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BillLine {
pub description: String,
/// negative for a discount line
pub amount: Money,
/// false for lines the charge does not apply to
pub eligible: bool,
}
/// The charge and the totals around it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceCharge {
/// the lines the charge is worked out on
pub eligible_total: Money,
pub basis_points: i64,
pub charge: Money,
/// every line, before the charge
pub subtotal: Money,
/// subtotal plus the charge
pub total: Money,
}
Your code names it in one line, in the file that uses it
fune!(hospitality.service-charge@^1); // then call service_charge(…)
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::money_add::add_money; ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{money_from_value, money_to_value, Money}; ← 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
use super::money_sum::sum_money; ← from money.sum ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics on an empty bill, a rate outside 0 to 10000, mixed currencies or a
/// negative eligible total.
pub fn service_charge(lines: &[BillLine], basis_points: i64, mode: &str) -> ServiceCharge {
if lines.is_empty() {
panic!("a bill needs at least one line");
}
if !(0..=10000).contains(&basis_points) {
panic!("basisPoints must be a whole number from 0 to 10000, received {}", basis_points);
}
let currency = lines[0].amount.currency.clone();
let all: Vec<Money> = lines.iter().map(|l| l.amount.clone()).collect();
let eligible: Vec<Money> = lines.iter().filter(|l| l.eligible).map(|l| l.amount.clone()).collect();
let subtotal = sum_money(&all, ¤cy);
let eligible_total = sum_money(&eligible, ¤cy);
if eligible_total.minor < 0 {
panic!(
"the eligible lines total {}, and a service charge cannot be negative",
eligible_total.minor
);
}
let charge = apply_rate(&eligible_total, basis_points, mode);
ServiceCharge {
total: add_money(&subtotal, &charge),
eligible_total,
basis_points,
charge,
subtotal,
}
}
pub fn bill_line_from_value(v: &Value) -> BillLine {
BillLine {
description: v.get("description").as_str().to_string(),
amount: money_from_value(v.get("amount")),
eligible: v.get("eligible").as_bool(),
}
}
pub fn service_charge_to_value(s: &ServiceCharge) -> Value {
Value::obj(vec![
("eligibleTotal", money_to_value(&s.eligible_total)),
("basisPoints", Value::Int(s.basis_points)),
("charge", money_to_value(&s.charge)),
("subtotal", money_to_value(&s.subtotal)),
("total", money_to_value(&s.total)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let lines: Vec<BillLine> = args[0].as_arr().iter().map(bill_line_from_value).collect();
service_charge_to_value(&service_charge(&lines, args[1].as_i64(), args[2].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 5 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 hospitality.service-charge
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./hospitality.service-charge-1.0.0-rust.fune, or fetch it from a terminal with fune pull hospitality.service-charge@1.0.0:rust.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,771 |
| impl/python.py | 1,596 |
| impl/rust.rs | 2,499 |
| impl/typescript.ts | 1,597 |
| vectors.json | 7,226 |