Functional Weave
Code in Python

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 delivery
  • basket_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 summary
  • basket_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.

def basket_total(lines: Sequence[BasketLine], promotions: Sequence[Promotion], basket_discount: Money, delivery: Money, delivery_tax_category: str, prices_include_vat: bool, jurisdiction: str, on_date: str) -> Basket
linesBasketLine[]at least one, one currency
promotionsPromotion[]offers that may apply (retail.promotion-apply); [] for none
basket_discountMoneya coupon or other discount on the whole basket; 0 for none
deliveryMoneythe delivery charge (retail.shipping-rate), priced like the lines
delivery_tax_categorystringthe VAT category of the delivery charge, usually STANDARD
prices_include_vatbooltrue for consumer prices (VAT inside), false for trade prices (VAT on top)
jurisdictionstringISO 3166-1 alpha-2, as finance.tax.vat-rate knows it
on_datedatethe tax point, which decides the VAT rates
returnsBasket

The types it declares, generated into your project

@dataclass(frozen=True)
class BasketLine:
    """One line as the shop prices it."""

    sku: str
    unit_price: Money
    quantity: int
    #: STANDARD, REDUCED, ZERO, EXEMPT or HOSPITALITY
    tax_category: str

@dataclass(frozen=True)
class BasketLineTotal:
    """One line, from shelf price to what the customer pays."""

    sku: str
    quantity: int
    #: unit price x quantity, as priced
    gross: Money
    promotion_discount: Money
    #: this line's share of the basket discount
    basket_discount: Money
    #: excluding VAT
    net: Money
    tax: Money
    #: including VAT
    total: Money

@dataclass(frozen=True)
class BasketVatGroup:
    """The VAT summary for one rate."""

    basis_points: int
    net: Money
    tax: Money

@dataclass(frozen=True)
class Basket:
    """The whole checkout."""

    lines: List[BasketLineTotal]
    promotions: List[AppliedPromotion]
    delivery: VatBreakdown
    #: one group per rate, lowest rate first, delivery included when it is not zero
    vat_breakdown: List[BasketVatGroup]
    #: the lines' gross, before any discount
    subtotal: Money
    promotion_discount: Money
    basket_discount: Money
    #: everything excluding VAT, delivery included
    net: Money
    tax: Money
    #: what the customer pays
    total: Money

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

from fune.retail.basket_total import basket_total  # retail.basket-total@^1
impl/python.py · 100 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 Dict, List, Sequence, Tuple

from .finance_tax_add_vat import VatBreakdown, add_vat  ← from finance.tax.add-vat ^1.0.0 · built alongside by fune
from .finance_tax_remove_vat import remove_vat  ← from finance.tax.remove-vat ^1.0.0 · built alongside by fune
from .money_allocate import allocate  ← from money.allocate ^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_sum import sum_money  ← from money.sum ^1.0.0 · built alongside by fune
from .retail_promotion_apply import PromoLine, Promotion, apply_promotions  ← from retail.promotion-apply ^1.0.0 · built alongside by fune
from .retail_basket_total_types import BasketLine, BasketLineTotal, BasketVatGroup, Basket


def basket_total(
    lines: Sequence[BasketLine],
    promotions: Sequence[Promotion],
    basket_discount: Money,
    delivery: Money,
    delivery_tax_category: str,
    prices_include_vat: bool,
    jurisdiction: str,
    on_date: str,
) -> Basket:
    """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.
    """
    priced = apply_promotions(
        [PromoLine(sku=l.sku, unit_price=l.unit_price, quantity=l.quantity) for l in lines],
        promotions,
    )
    currency = priced.subtotal.currency
    for name, amount in (("basketDiscount", basket_discount), ("delivery", delivery)):
        if amount.currency != currency:
            raise ValueError("currency mismatch: %s and %s" % (currency, amount.currency))
        if amount.minor < 0:
            raise ValueError("%s must not be negative, received %d" % (name, amount.minor))
    if basket_discount.minor > priced.total.minor:
        raise ValueError(
            "basketDiscount %d is more than the basket's %d after promotions"
            % (basket_discount.minor, priced.total.minor)
        )

    after_promotions = [l.net.minor for l in priced.lines]
    if basket_discount.minor == 0:
        shares = [money(0, currency) for _ in after_promotions]
    else:
        shares = allocate(basket_discount, after_promotions)

    def vat(amount: Money, category: str) -> VatBreakdown:
        if prices_include_vat:
            return remove_vat(amount, jurisdiction, category, on_date)
        return add_vat(amount, jurisdiction, category, on_date)

    rates: List[int] = []
    totals: List[BasketLineTotal] = []
    for i, line in enumerate(lines):
        v = vat(money(after_promotions[i] - shares[i].minor, currency), line.tax_category)
        rates.append(v.basis_points)
        totals.append(
            BasketLineTotal(
                sku=line.sku,
                quantity=line.quantity,
                gross=priced.lines[i].gross,
                promotion_discount=priced.lines[i].discount,
                basket_discount=shares[i],
                net=v.net,
                tax=v.tax,
                total=v.gross,
            )
        )
    delivery_vat = vat(delivery, delivery_tax_category)

    # Grouped by rate, lowest first, so the receipt's VAT summary is stable.
    groups: Dict[int, Tuple[int, int]] = {}

    def add(bp: int, net: Money, tax: Money) -> None:
        n, t = groups.get(bp, (0, 0))
        groups[bp] = (n + net.minor, t + tax.minor)

    for i, t in enumerate(totals):
        add(rates[i], t.net, t.tax)
    if delivery.minor != 0:
        add(delivery_vat.basis_points, delivery_vat.net, delivery_vat.tax)
    vat_breakdown = [
        BasketVatGroup(basis_points=bp, net=money(n, currency), tax=money(t, currency))
        for bp, (n, t) in sorted(groups.items())
    ]

    return Basket(
        lines=totals,
        promotions=priced.applied,
        delivery=delivery_vat,
        vat_breakdown=vat_breakdown,
        subtotal=priced.subtotal,
        promotion_discount=priced.discount,
        basket_discount=basket_discount,
        net=sum_money([t.net for t in totals] + [delivery_vat.net], currency),
        tax=sum_money([t.tax for t in totals] + [delivery_vat.tax], currency),
        total=sum_money([t.total for t in totals] + [delivery_vat.gross], 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 6 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 retail.basket-total
Download for Python retail.basket-total-1.0.0-python.fune · 24,122 bytes sha256 83d69483100f2a87170d960cc1b56733ce9b110d577e6656dd8466584e669373

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./retail.basket-total-1.0.0-python.fune, or fetch it from a terminal with fune pull retail.basket-total@1.0.0:python.

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.

CaseArgumentsExpected
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

PathBytes
README.md2,477
impl/python.py3,912
impl/rust.rs7,595
impl/typescript.ts3,748
vectors.json10,196