retail.basket-total
Total a basket: line prices, promotions, a basket discount, VAT per line and delivery, into one checked total.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 10 tests, run in TypeScript, Python and Rust.
What it does
The checkout total, from shelf prices to the amount charged, with the VAT split an invoice or receipt needs. It invents no arithmetic: promotions are `retail.promotion-apply`, discount sharing is `money.allocate`, VAT is `finance.tax.remove-vat` or `finance.tax.add-vat`, and each is pinned by its own vectors. What this function decides is the order.
## Order of operations
For example
basket_total(lines ×3, promotions ×1, £5.00, £3.95, STANDARD, true, GB, 2026-09-15)→ lines ×3, promotions ×1, delivery …, vat breakdown ×2, subtotal £40.00, promotion discount £9.00, basket discount £5.00, net £25.65, tax £4.30, total £29.95 a consumer basket: 3 for 2 on wine, a 5.00 coupon shared by line, VAT extracted per line, taxed deliverybasket_total(lines ×2, , £0.00, £0.00, ZERO, false, GB, 2026-09-15)→ lines ×2, promotions , delivery …, vat breakdown ×2, subtotal £125.00, promotion discount £0.00, basket discount £0.00, net £125.00, tax £20.00, total £145.00 a trade basket: VAT added on top, zero-rated delivery of nothing left out of the summarybasket_total(lines ×1, , £1.00, £0.00, STANDARD, false, GB, 2026-09-15)→ lines ×1, promotions , delivery …, vat breakdown ×1, subtotal £10.00, promotion discount £0.00, basket discount £1.00, net £9.00, tax £1.80, total £10.80 VAT is on the discounted price: 10.00 less 1.00 plus VAT is 10.80, not 11.00
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 basket_total(lines: &[BasketLine], promotions: &[Promotion], basket_discount: &Money, delivery: &Money, delivery_tax_category: &str, prices_include_vat: bool, jurisdiction: &str, on_date: &str) -> Basket
| lines | BasketLine[] | at least one, one currency |
| promotions | Promotion[] | offers that may apply (retail.promotion-apply); [] for none |
| basket_discount | Money | a coupon or other discount on the whole basket; 0 for none |
| delivery | Money | the delivery charge (retail.shipping-rate), priced like the lines |
| delivery_tax_category | string | the VAT category of the delivery charge, usually STANDARD |
| prices_include_vat | bool | true for consumer prices (VAT inside), false for trade prices (VAT on top) |
| jurisdiction | string | ISO 3166-1 alpha-2, as finance.tax.vat-rate knows it |
| on_date | date | the tax point, which decides the VAT rates |
| returns | Basket |
The types it declares, generated into your project
/// One line as the shop prices it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BasketLine {
pub sku: String,
pub unit_price: Money,
pub quantity: i64,
/// STANDARD, REDUCED, ZERO, EXEMPT or HOSPITALITY
pub tax_category: String,
}
/// One line, from shelf price to what the customer pays.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BasketLineTotal {
pub sku: String,
pub quantity: i64,
/// unit price x quantity, as priced
pub gross: Money,
pub promotion_discount: Money,
/// this line's share of the basket discount
pub basket_discount: Money,
/// excluding VAT
pub net: Money,
pub tax: Money,
/// including VAT
pub total: Money,
}
/// The VAT summary for one rate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BasketVatGroup {
pub basis_points: i64,
pub net: Money,
pub tax: Money,
}
/// The whole checkout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Basket {
pub lines: Vec<BasketLineTotal>,
pub promotions: Vec<AppliedPromotion>,
pub delivery: VatBreakdown,
/// one group per rate, lowest rate first, delivery included when it is not zero
pub vat_breakdown: Vec<BasketVatGroup>,
/// the lines' gross, before any discount
pub subtotal: Money,
pub promotion_discount: Money,
pub basket_discount: Money,
/// everything excluding VAT, delivery included
pub net: Money,
pub tax: Money,
/// what the customer pays
pub total: Money,
}
Your code names it in one line, in the file that uses it
fune!(retail.basket-total@^1); // then call basket_total(…)
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::finance_tax_add_vat::{add_vat, vat_breakdown_to_value, VatBreakdown}; ← from finance.tax.add-vat ^1.0.0 · built alongside by fune
use super::finance_tax_remove_vat::remove_vat; ← from finance.tax.remove-vat ^1.0.0 · built alongside by fune
use super::money_allocate::allocate; ← from money.allocate ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
use super::money_sum::sum_money; ← from money.sum ^1.0.0 · built alongside by fune
use super::retail_promotion_apply::{
applied_promotion_to_value, apply_promotions, promotion_from_value, PromoLine, Promotion,
};
/// The checkout: promotions, then the basket discount shared across the
/// lines, then VAT per line on what the line really costs, then delivery.
///
/// No arithmetic lives here; the order of operations is the whole function.
///
/// # Panics
/// Panics on mixed currencies, negative discounts or delivery, a discount
/// bigger than the basket, or anything its dependencies refuse.
#[allow(clippy::too_many_arguments)]
pub fn basket_total(
lines: &[BasketLine],
promotions: &[Promotion],
basket_discount: &Money,
delivery: &Money,
delivery_tax_category: &str,
prices_include_vat: bool,
jurisdiction: &str,
on_date: &str,
) -> Basket {
let promo_lines: Vec<PromoLine> = lines
.iter()
.map(|l| PromoLine {
sku: l.sku.clone(),
unit_price: l.unit_price.clone(),
quantity: l.quantity,
})
.collect();
let priced = apply_promotions(&promo_lines, promotions);
let currency = priced.subtotal.currency.clone();
for (name, amount) in [("basketDiscount", basket_discount), ("delivery", delivery)] {
if amount.currency != currency {
panic!("currency mismatch: {} and {}", currency, amount.currency);
}
if amount.minor < 0 {
panic!("{} must not be negative, received {}", name, amount.minor);
}
}
if basket_discount.minor > priced.total.minor {
panic!(
"basketDiscount {} is more than the basket's {} after promotions",
basket_discount.minor, priced.total.minor
);
}
let after_promotions: Vec<i64> = priced.lines.iter().map(|l| l.net.minor).collect();
let shares: Vec<Money> = if basket_discount.minor == 0 {
after_promotions.iter().map(|_| money(0, ¤cy)).collect()
} else {
allocate(basket_discount, &after_promotions)
};
let vat = |amount: &Money, category: &str| -> VatBreakdown {
if prices_include_vat {
remove_vat(amount, jurisdiction, category, on_date)
} else {
add_vat(amount, jurisdiction, category, on_date)
}
};
let mut rates: Vec<i64> = Vec::new();
let mut totals: Vec<BasketLineTotal> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let v = vat(&money(after_promotions[i] - shares[i].minor, ¤cy), &line.tax_category);
rates.push(v.basis_points);
totals.push(BasketLineTotal {
sku: line.sku.clone(),
quantity: line.quantity,
gross: priced.lines[i].gross.clone(),
promotion_discount: priced.lines[i].discount.clone(),
basket_discount: shares[i].clone(),
net: v.net,
tax: v.tax,
total: v.gross,
});
}
let delivery_vat = vat(delivery, delivery_tax_category);
// Grouped by rate, lowest first, so the receipt's VAT summary is stable.
let mut groups: Vec<(i64, i64, i64)> = Vec::new();
let mut add = |bp: i64, net: i64, tax: i64| match groups.iter_mut().find(|g| g.0 == bp) {
Some(g) => {
g.1 += net;
g.2 += tax;
}
None => groups.push((bp, net, tax)),
};
for (i, t) in totals.iter().enumerate() {
add(rates[i], t.net.minor, t.tax.minor);
}
if delivery.minor != 0 {
add(delivery_vat.basis_points, delivery_vat.net.minor, delivery_vat.tax.minor);
}
groups.sort_by_key(|g| g.0);
let vat_breakdown: Vec<BasketVatGroup> = groups
.iter()
.map(|&(bp, n, t)| BasketVatGroup {
basis_points: bp,
net: money(n, ¤cy),
tax: money(t, ¤cy),
})
.collect();
let mut nets: Vec<Money> = totals.iter().map(|t| t.net.clone()).collect();
nets.push(delivery_vat.net.clone());
let mut taxes: Vec<Money> = totals.iter().map(|t| t.tax.clone()).collect();
taxes.push(delivery_vat.tax.clone());
let mut grosses: Vec<Money> = totals.iter().map(|t| t.total.clone()).collect();
grosses.push(delivery_vat.gross.clone());
Basket {
lines: totals,
promotions: priced.applied,
delivery: delivery_vat,
vat_breakdown,
subtotal: priced.subtotal,
promotion_discount: priced.discount,
basket_discount: basket_discount.clone(),
net: sum_money(&nets, ¤cy),
tax: sum_money(&taxes, ¤cy),
total: sum_money(&grosses, ¤cy),
}
}
pub fn basket_line_from_value(v: &Value) -> BasketLine {
BasketLine {
sku: v.get("sku").as_str().to_string(),
unit_price: money_from_value(v.get("unitPrice")),
quantity: v.get("quantity").as_i64(),
tax_category: v.get("taxCategory").as_str().to_string(),
}
}
pub fn basket_to_value(b: &Basket) -> Value {
Value::obj(vec![
(
"lines",
Value::Arr(
b.lines
.iter()
.map(|l| {
Value::obj(vec![
("sku", Value::str(&l.sku)),
("quantity", Value::Int(l.quantity)),
("gross", money_to_value(&l.gross)),
("promotionDiscount", money_to_value(&l.promotion_discount)),
("basketDiscount", money_to_value(&l.basket_discount)),
("net", money_to_value(&l.net)),
("tax", money_to_value(&l.tax)),
("total", money_to_value(&l.total)),
])
})
.collect(),
),
),
("promotions", Value::Arr(b.promotions.iter().map(applied_promotion_to_value).collect())),
("delivery", vat_breakdown_to_value(&b.delivery)),
(
"vatBreakdown",
Value::Arr(
b.vat_breakdown
.iter()
.map(|g| {
Value::obj(vec![
("basisPoints", Value::Int(g.basis_points)),
("net", money_to_value(&g.net)),
("tax", money_to_value(&g.tax)),
])
})
.collect(),
),
),
("subtotal", money_to_value(&b.subtotal)),
("promotionDiscount", money_to_value(&b.promotion_discount)),
("basketDiscount", money_to_value(&b.basket_discount)),
("net", money_to_value(&b.net)),
("tax", money_to_value(&b.tax)),
("total", money_to_value(&b.total)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let lines: Vec<BasketLine> = args[0].as_arr().iter().map(basket_line_from_value).collect();
let promotions: Vec<Promotion> = args[1].as_arr().iter().map(promotion_from_value).collect();
basket_to_value(&basket_total(
&lines,
&promotions,
&money_from_value(&args[2]),
&money_from_value(&args[3]),
args[4].as_str(),
args[5].as_bool(),
args[6].as_str(),
args[7].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 6 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 retail.basket-total
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./retail.basket-total-1.0.0-rust.fune, or fetch it from a terminal with fune pull retail.basket-total@1.0.0:rust.
The whole function, every language, is one file too: retail.basket-total-1.0.0.fune, 35,866 bytes, sha256 21ebf48aa63a287997a9b3c5add548af26f56619552fe27219b1573eda0cb56d. 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 retail.basket-total
after — your function gets the result and the arguments, and returns the final result.
// fune: after retail.basket-total
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 finance.tax.add-vat in retail.basket-total
// fune: replace finance.tax.remove-vat in retail.basket-total
// fune: replace money.allocate in retail.basket-total
// fune: replace money.amount in retail.basket-total
// fune: replace money.sum in retail.basket-total
// fune: replace retail.promotion-apply in retail.basket-total
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 retail.basket-total --steps.
// fune: step retail.basket-total 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 | |
|---|---|---|---|
| a consumer basket: 3 for 2 on wine, a 5.00 coupon shared by line, VAT extracted per line, taxed delivery | lines ×3, promotions ×1, £5.00, £3.95, STANDARD, true, GB, 2026-09-15 | → | lines ×3, promotions ×1, delivery …, vat breakdown ×2, subtotal £40.00, promotion discount £9.00, basket discount £5.00, net £25.65, tax £4.30, total £29.95 |
| a trade basket: VAT added on top, zero-rated delivery of nothing left out of the summary | lines ×2, , £0.00, £0.00, ZERO, false, GB, 2026-09-15 | → | lines ×2, promotions , delivery …, vat breakdown ×2, subtotal £125.00, promotion discount £0.00, basket discount £0.00, net £125.00, tax £20.00, total £145.00 |
| VAT is on the discounted price: 10.00 less 1.00 plus VAT is 10.80, not 11.00 | lines ×1, , £1.00, £0.00, STANDARD, false, GB, 2026-09-15 | → | lines ×1, promotions , delivery …, vat breakdown ×1, subtotal £10.00, promotion discount £0.00, basket discount £1.00, net £9.00, tax £1.80, total £10.80 |
| the tax point picks the rate: 15% in 2009 | lines ×1, , £0.00, £0.00, STANDARD, false, GB, 2009-06-01 | → | lines ×1, promotions , delivery …, vat breakdown ×1, subtotal £10.00, promotion discount £0.00, basket discount £0.00, net £10.00, tax £1.50, total £11.50 |
| a coupon that uses up the whole basket leaves only delivery | lines ×1, , £12.00, £6.00, STANDARD, true, GB, 2026-09-15 | → | lines ×1, promotions , delivery …, vat breakdown ×1, subtotal £12.00, promotion discount £0.00, basket discount £12.00, net £5.00, tax £1.00, total £6.00 |
| a coupon bigger than the basket after promotions is an error | lines ×3, promotions ×1, £50.00, £0.00, STANDARD, true, GB, 2026-09-15 | → | error: basketDiscount 5000 is more than the basket's 3100 after promotions |
| delivery in another currency is an error | lines ×3, , £0.00, €3.95, STANDARD, true, GB, 2026-09-15 | → | error: currency mismatch: GBP and EUR |
| an empty basket is an error | , , £0.00, £0.00, STANDARD, true, GB, 2026-09-15 | → | error: a basket needs at least one line |
| an unknown tax category is an error | lines ×1, , £0.00, £0.00, STANDARD, true, GB, 2026-09-15 | → | error: no VAT rule for GB/LUXURY |
| a negative delivery charge is an error | lines ×3, , £0.00, -£0.01, STANDARD, true, GB, 2026-09-15 | → | error: delivery must not be negative |
More from the author
1. **Promotions** on the lines, best for the customer, each deal's saving allocated back to the lines in it. 2. **Basket discount** (a coupon, a staff discount) shared across every line in proportion to what the line costs after promotions, with `money.allocate`, so the shares add up to the discount exactly. It may not exceed the basket after promotions. 3. **VAT per line**, on what the line costs after both discounts, at the rate for its category on `onDate`. Consumer prices include VAT (`pricesIncludeVat` true), so VAT is extracted with `remove-vat`; trade prices exclude it, so it is added with `add-vat`. Either way VAT is on the discounted price, which is what HMRC requires for a discount given at the time of sale (Notice 700/7): taxing the shelf price and then taking the coupon off overcharges VAT. 4. **Delivery** is taxed the same way, at `deliveryTaxCategory`. In the UK, delivery charged by the seller of goods follows the VAT liability of the goods, and a basket of mixed-rate goods has to apportion it; that apportionment is the caller's decision (pass the category that applies, or split delivery across calls). 5. **Totals** are sums of the per-line figures, so the receipt lines always add up to the total, and the VAT summary groups them by rate, delivery included when it is not zero.
`total = subtotal - promotionDiscount - basketDiscount + delivery` for VAT-inclusive prices, and `total = net + tax` always.
## Sources
- VAT on discounts: HMRC, "Business promotions (VAT Notice 700/7)", section 7.3 (on redeeming money-off coupons, VAT is accounted for "on the amount due from the customer"): https://www.gov.uk/guidance/business-promotions-and-vat-notice-7007 - Delivery: HMRC, "Postage, delivery and direct marketing (VAT Notice 700/24)" (delivery charged by the seller follows the goods; mixed-rate apportionment must be fair and justifiable, with no set method): https://www.gov.uk/guidance/vat-on-postage-delivery-and-direct-marketing-notice-70024 - Rates: `finance.tax.vat-rate`.
Files
| Path | Bytes |
|---|---|
| README.md | 2,477 |
| impl/python.py | 3,912 |
| impl/rust.rs | 7,595 |
| impl/typescript.ts | 3,748 |
| vectors.json | 10,196 |