Functional Weave
Code in Rust

retail.promotion-apply

Apply BOGOF, 3-for-2, percent off, amount off and multibuy deals to a basket, best for the customer, allocated to lines.

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

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

What it does

Prices a basket under the retailer's offers: buy one get one free, 3 for 2, percent off, an amount off, and multibuys like 3 for £10, including mix-and-match across SKUs. When offers compete for the same items the customer gets the combination that saves the most, and every discount is shared back to the lines to the penny.

## The four kinds

For example

  • apply_promotions(lines ×1, promotions ×1) → lines ×1, applied ×1, subtotal £6.00, discount £3.00, total £3.00 BOGOF on four: two free
  • apply_promotions(lines ×1, promotions ×1) → lines ×1, applied ×1, subtotal £4.50, discount £1.50, total £3.00 BOGOF on three: the odd one pays full price
  • apply_promotions(lines ×3, promotions ×1) → lines ×3, applied ×1, subtotal £22.97, discount £5.99, total £16.98 3 for 2 mix and match: the cheapest is free, and the saving is shared by price

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 apply_promotions(lines: &[PromoLine], promotions: &[Promotion]) -> PromotionResult
linesPromoLine[]the basket; at least one line, one currency
promotionsPromotion[]the offers that may apply; order only breaks ties
returnsPromotionResult

The types it declares, generated into your project

/// One basket line before promotions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromoLine {
    pub sku: String,
    /// 0 or more
    pub unit_price: Money,
    /// 1 or more
    pub quantity: i64,
}

/// One offer. Units of any SKU in skus combine into its groups (mix and match).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Promotion {
    pub id: String,
    pub kind: String,
    /// the qualifying SKUs
    pub skus: Vec<String>,
    /// items per deal: 1 for a plain percent off each item, 2 for BOGOF, 3 for 3-for-2 or 3 for 10.00
    pub group_size: i64,
    /// free-items only: the cheapest this many of each group are free; 0 otherwise
    pub free_items: i64,
    /// percent-off only: 2500 = 25% off each group; 0 otherwise
    pub basis_points: i64,
    /// amount-off: taken off each group, at most its price; group-price: what each group costs; null otherwise
    pub amount: Option<Money>,
}

// PromotionKind is a string in Rust, one of: "percent-off", "amount-off", "free-items", "group-price".
// Parameters take it as &str and results hold it as String.

/// A basket line after promotions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PricedLine {
    pub sku: String,
    pub quantity: i64,
    /// unit price x quantity
    pub gross: Money,
    /// this line's share of every deal it was part of
    pub discount: Money,
    /// gross - discount
    pub net: Money,
}

/// A promotion that applied, and what it saved.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedPromotion {
    pub id: String,
    /// how many times it applied
    pub groups: i64,
    pub discount: Money,
}

/// The priced basket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromotionResult {
    /// in basket order
    pub lines: Vec<PricedLine>,
    /// in the order promotions were given; promotions that did not apply are left out
    pub applied: Vec<AppliedPromotion>,
    pub subtotal: Money,
    pub discount: Money,
    pub total: Money,
}

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

fune!(retail.promotion-apply@^1);  // then call apply_promotions(…)
impl/rust.rs · 328 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.

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_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_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

const MAX_COMPETING: usize = 6;

// A unit is (line index, unit price); a group is (unit indices, discount).
type Unit = (usize, i64);
type Group = (Vec<usize>, i64);

fn check_promotion(p: &Promotion, currency: &str) {
    if p.group_size < 1 {
        panic!("promotion \"{}\": groupSize must be 1 or more", p.id);
    }
    match p.kind.as_str() {
        "free-items" => {
            if p.free_items < 1 || p.free_items >= p.group_size {
                panic!("promotion \"{}\": freeItems must be from 1 to groupSize - 1", p.id);
            }
        }
        "percent-off" => {
            if p.basis_points < 1 || p.basis_points > 10000 {
                panic!("promotion \"{}\": basisPoints must be from 1 to 10000", p.id);
            }
        }
        "amount-off" | "group-price" => match &p.amount {
            None => panic!("promotion \"{}\" needs an amount", p.id),
            Some(amount) => {
                if amount.currency != currency {
                    panic!("currency mismatch: {} and {}", currency, amount.currency);
                }
                if amount.minor < 0 {
                    panic!("promotion \"{}\": amount must not be negative", p.id);
                }
            }
        },
        other => panic!("promotion \"{}\": unknown kind \"{}\"", p.id, other),
    }
}

// The qualifying unclaimed units from dearest to cheapest (ties in basket
// order), cut into consecutive groups of group_size. Groups that would save
// nothing are skipped.
fn evaluate(p: &Promotion, units: &[Unit], skus: &[String], claimed: &[bool], currency: &str) -> Vec<Group> {
    let mut pool: Vec<usize> = (0..units.len())
        .filter(|&i| !claimed[i] && p.skus.contains(&skus[units[i].0]))
        .collect();
    pool.sort_by(|&a, &b| units[b].1.cmp(&units[a].1).then(a.cmp(&b)));
    let size = p.group_size as usize;
    let mut groups: Vec<Group> = Vec::new();
    let mut start = 0;
    while start + size <= pool.len() {
        let members: Vec<usize> = pool[start..start + size].to_vec();
        let prices: Vec<i64> = members.iter().map(|&i| units[i].1).collect();
        let total: i64 = prices.iter().sum();
        let discount = match p.kind.as_str() {
            "free-items" => prices[size - p.free_items as usize..].iter().sum(),
            "percent-off" => apply_rate(&money(total, currency), p.basis_points, "half-up").minor,
            "amount-off" => p.amount.as_ref().unwrap().minor.min(total),
            _ => (total - p.amount.as_ref().unwrap().minor).max(0),
        };
        if discount > 0 {
            groups.push((members, discount));
        }
        start += size;
    }
    groups
}

fn run(
    order: &[usize],
    promotions: &[Promotion],
    units: &[Unit],
    skus: &[String],
    claimed: &mut Vec<bool>,
    currency: &str,
) -> (i64, Vec<(usize, Vec<Group>)>) {
    let mut groups: Vec<(usize, Vec<Group>)> = Vec::new();
    let mut total = 0;
    for &index in order {
        let made = evaluate(&promotions[index], units, skus, claimed, currency);
        for (members, discount) in &made {
            for &u in members {
                claimed[u] = true;
            }
            total += discount;
        }
        groups.push((index, made));
    }
    (total, groups)
}

fn next_permutation(a: &mut [usize]) -> bool {
    if a.len() < 2 {
        return false;
    }
    let mut i = a.len() - 1;
    while i > 0 && a[i - 1] >= a[i] {
        i -= 1;
    }
    if i == 0 {
        return false;
    }
    let mut j = a.len() - 1;
    while a[j] <= a[i - 1] {
        j -= 1;
    }
    a.swap(i - 1, j);
    a[i..].reverse();
    true
}

fn find(parent: &[usize], mut i: usize) -> usize {
    while parent[i] != i {
        i = parent[i];
    }
    i
}

/// Price a basket under competing promotions: the customer gets the order of
/// application that saves the most, and each deal's saving is allocated back
/// to the lines in its groups exactly.
///
/// # Panics
/// Panics on an empty basket, mixed currencies, a bad quantity or price, a
/// malformed or duplicate promotion, or more than six competing promotions.
pub fn apply_promotions(lines: &[PromoLine], promotions: &[Promotion]) -> PromotionResult {
    if lines.is_empty() {
        panic!("a basket needs at least one line");
    }
    let currency = lines[0].unit_price.currency.clone();
    let skus: Vec<String> = lines.iter().map(|l| l.sku.clone()).collect();
    let mut units: Vec<Unit> = Vec::new();
    for (index, line) in lines.iter().enumerate() {
        if line.unit_price.currency != currency {
            panic!("currency mismatch: {} and {}", currency, line.unit_price.currency);
        }
        if line.quantity < 1 {
            panic!("quantity must be 1 or more, received {}", line.quantity);
        }
        if line.unit_price.minor < 0 {
            panic!("unitPrice must not be negative, received {}", line.unit_price.minor);
        }
        for _ in 0..line.quantity {
            units.push((index, line.unit_price.minor));
        }
    }
    for (i, p) in promotions.iter().enumerate() {
        if promotions[..i].iter().any(|q| q.id == p.id) {
            panic!("duplicate promotion id \"{}\"", p.id);
        }
        check_promotion(p, &currency);
    }

    // Promotions with something to act on, joined into sets that share a SKU
    // present in the basket. Only promotions in the same set compete.
    let relevant: Vec<usize> = (0..promotions.len())
        .filter(|&i| promotions[i].skus.iter().any(|s| skus.contains(s)))
        .collect();
    let mut parent: Vec<usize> = (0..promotions.len()).collect();
    for x in 0..relevant.len() {
        for y in x + 1..relevant.len() {
            let a = &promotions[relevant[x]];
            let b = &promotions[relevant[y]];
            if a.skus.iter().any(|s| skus.contains(s) && b.skus.contains(s)) {
                let ra = find(&parent, relevant[x]);
                let rb = find(&parent, relevant[y]);
                if ra != rb {
                    parent[ra.max(rb)] = ra.min(rb);
                }
            }
        }
    }
    let mut components: Vec<(usize, Vec<usize>)> = Vec::new();
    for &i in &relevant {
        let root = find(&parent, i);
        match components.iter_mut().find(|(r, _)| *r == root) {
            Some((_, members)) => members.push(i),
            None => components.push((root, vec![i])),
        }
    }

    let mut claimed = vec![false; units.len()];
    let mut chosen: Vec<Vec<Group>> = vec![Vec::new(); promotions.len()];
    for (_, component) in &components {
        if component.len() > MAX_COMPETING {
            let ids: Vec<&str> = component.iter().map(|&i| promotions[i].id.as_str()).collect();
            panic!(
                "more than {} promotions compete for the same items: {}",
                MAX_COMPETING,
                ids.join(", ")
            );
        }
        let mut order = component.clone();
        let mut best_order = order.clone();
        let mut best_total = -1;
        loop {
            let mut trial = claimed.clone();
            let (total, _) = run(&order, promotions, &units, &skus, &mut trial, &currency);
            if total > best_total {
                best_total = total;
                best_order = order.clone();
            }
            if !next_permutation(&mut order) {
                break;
            }
        }
        let (_, groups) = run(&best_order, promotions, &units, &skus, &mut claimed, &currency);
        for (index, made) in groups {
            chosen[index] = made;
        }
    }

    let mut line_discount = vec![0i64; lines.len()];
    let mut applied: Vec<AppliedPromotion> = Vec::new();
    for (index, p) in promotions.iter().enumerate() {
        let made = &chosen[index];
        if made.is_empty() {
            continue;
        }
        let mut saved = 0;
        for (members, discount) in made {
            let ratios: Vec<i64> = members.iter().map(|&u| units[u].1).collect();
            let shares = allocate(&money(*discount, &currency), &ratios);
            for (k, &u) in members.iter().enumerate() {
                line_discount[units[u].0] += shares[k].minor;
            }
            saved += discount;
        }
        applied.push(AppliedPromotion {
            id: p.id.clone(),
            groups: made.len() as i64,
            discount: money(saved, &currency),
        });
    }

    let priced: Vec<PricedLine> = lines
        .iter()
        .enumerate()
        .map(|(index, line)| {
            let gross = line.unit_price.minor * line.quantity;
            PricedLine {
                sku: line.sku.clone(),
                quantity: line.quantity,
                gross: money(gross, &currency),
                discount: money(line_discount[index], &currency),
                net: money(gross - line_discount[index], &currency),
            }
        })
        .collect();
    let grosses: Vec<Money> = priced.iter().map(|l| l.gross.clone()).collect();
    let discounts: Vec<Money> = priced.iter().map(|l| l.discount.clone()).collect();
    let subtotal = sum_money(&grosses, &currency);
    let discount = sum_money(&discounts, &currency);
    PromotionResult {
        lines: priced,
        applied,
        total: money(subtotal.minor - discount.minor, &currency),
        subtotal,
        discount,
    }
}

pub fn promo_line_from_value(v: &Value) -> PromoLine {
    PromoLine {
        sku: v.get("sku").as_str().to_string(),
        unit_price: money_from_value(v.get("unitPrice")),
        quantity: v.get("quantity").as_i64(),
    }
}

pub fn promotion_from_value(v: &Value) -> Promotion {
    Promotion {
        id: v.get("id").as_str().to_string(),
        kind: v.get("kind").as_str().to_string(),
        skus: v.get("skus").as_arr().iter().map(|s| s.as_str().to_string()).collect(),
        group_size: v.get("groupSize").as_i64(),
        free_items: v.get("freeItems").as_i64(),
        basis_points: v.get("basisPoints").as_i64(),
        amount: if v.get("amount").is_null() {
            None
        } else {
            Some(money_from_value(v.get("amount")))
        },
    }
}

pub fn applied_promotion_to_value(a: &AppliedPromotion) -> Value {
    Value::obj(vec![
        ("id", Value::str(&a.id)),
        ("groups", Value::Int(a.groups)),
        ("discount", money_to_value(&a.discount)),
    ])
}

pub fn promotion_result_to_value(r: &PromotionResult) -> Value {
    Value::obj(vec![
        (
            "lines",
            Value::Arr(
                r.lines
                    .iter()
                    .map(|l| {
                        Value::obj(vec![
                            ("sku", Value::str(&l.sku)),
                            ("quantity", Value::Int(l.quantity)),
                            ("gross", money_to_value(&l.gross)),
                            ("discount", money_to_value(&l.discount)),
                            ("net", money_to_value(&l.net)),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("applied", Value::Arr(r.applied.iter().map(applied_promotion_to_value).collect())),
        ("subtotal", money_to_value(&r.subtotal)),
        ("discount", money_to_value(&r.discount)),
        ("total", money_to_value(&r.total)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let lines: Vec<PromoLine> = args[0].as_arr().iter().map(promo_line_from_value).collect();
    let promotions: Vec<Promotion> = args[1].as_arr().iter().map(promotion_from_value).collect();
    promotion_result_to_value(&apply_promotions(&lines, &promotions))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 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.promotion-apply
Download for Rust retail.promotion-apply-1.0.0-rust.fune · 40,617 bytes sha256 b465a0ff180750ad7916d1534cf824e6adac20ff2953602ec504dc27d24fafc9

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./retail.promotion-apply-1.0.0-rust.fune, or fetch it from a terminal with fune pull retail.promotion-apply@1.0.0:rust.

The whole function, every language, is one file too: retail.promotion-apply-1.0.0.fune, 57,590 bytes, sha256 543033a8dadfeb6e49c6ae44fbfe8fa644df4566a12ee6e4fd45ef2cb9ce94bd. 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.promotion-apply

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

// fune: after retail.promotion-apply

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 money.allocate in retail.promotion-apply
// fune: replace money.amount in retail.promotion-apply
// fune: replace money.apply-rate in retail.promotion-apply
// fune: replace money.sum in retail.promotion-apply

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.promotion-apply --steps.

// fune: step retail.promotion-apply 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
BOGOF on four: two free lines ×1, promotions ×1 → lines ×1, applied ×1, subtotal £6.00, discount £3.00, total £3.00
BOGOF on three: the odd one pays full price lines ×1, promotions ×1 → lines ×1, applied ×1, subtotal £4.50, discount £1.50, total £3.00
3 for 2 mix and match: the cheapest is free, and the saving is shared by price lines ×3, promotions ×1 → lines ×3, applied ×1, subtotal £22.97, discount £5.99, total £16.98
3 for 2 on four items groups the dearest three, so the 6.00 item is free, not the 3.00 one lines ×4, promotions ×1 → lines ×4, applied ×1, subtotal £27.00, discount £6.00, total £21.00
3 for 10.00 on one SKU lines ×1, promotions ×1 → lines ×1, applied ×1, subtotal £11.97, discount £1.97, total £10.00
3 for 10.00 mix and match, saving allocated by largest remainder lines ×3, promotions ×1 → lines ×3, applied ×1, subtotal £11.99, discount £1.99, total £10.00
a multibuy that would cost more than full price does not apply lines ×1, promotions ×1 → lines ×1, applied , subtotal £9.00, discount £0.00, total £9.00
25% off each item rounds per item: 3 x 5.00, not 25% of 59.97 lines ×1, promotions ×1 → lines ×1, applied ×1, subtotal £59.97, discount £15.00, total £44.97
5.00 off any two lines ×1, promotions ×1 → lines ×1, applied ×1, subtotal £24.00, discount £5.00, total £19.00
an amount off never takes an item below zero lines ×1, promotions ×1 → lines ×1, applied ×1, subtotal £3.00, discount £3.00, total £0.00
Show the other 13 tests
CaseArgumentsExpected
competing offers: 3 for 2 beats 25% off, whichever is listed first lines ×1, promotions ×2 → lines ×1, applied ×1, subtotal £24.00, discount £8.00, total £16.00
the best combination beats the biggest single saving first lines ×2, promotions ×2 → lines ×2, applied ×2, subtotal £30.00, discount £15.00, total £15.00
independent offers and an unpromoted line lines ×3, promotions ×2 → lines ×3, applied ×2, subtotal £13.19, discount £2.40, total £10.79
an offer for something not in the basket does nothing lines ×1, promotions ×1 → lines ×1, applied , subtotal £1.20, discount £0.00, total £1.20
no promotions at all lines ×1, → lines ×1, applied , subtotal £2.40, discount £0.00, total £2.40
two identical offers: the first listed wins the tie lines ×1, promotions ×2 → lines ×1, applied ×1, subtotal £3.00, discount £1.50, total £1.50
an empty basket is an error , promotions ×1 → error: a basket needs at least one line
mixed currencies are an error lines ×2, → error: currency mismatch: GBP and EUR
a free-items offer with every item free is an error lines ×1, promotions ×1 → error: promotion "bad": freeItems must be from 1 to groupSize - 1
a group price without an amount is an error lines ×1, promotions ×1 → error: promotion "deal" needs an amount
a zero quantity is an error lines ×1, → error: quantity must be 1 or more
duplicate promotion ids are an error lines ×1, promotions ×2 → error: duplicate promotion id "p1"
seven offers competing for the same items is an error lines ×1, promotions ×7 → error: more than 6 promotions compete for the same items

More from the author

Every promotion works on groups of `groupSize` qualifying items. Items from any SKU in `skus` mix and match.

| kind | each group | examples | |---|---|---| | `free-items` | the cheapest `freeItems` of the group are free | BOGOF (2, 1), 3 for 2 (3, 1) | | `group-price` | the group costs `amount` (never more than it did) | 3 for £10 (3, £10.00), meal deal | | `percent-off` | `basisPoints` off the group, rounded half up | 25% off (1, 2500) | | `amount-off` | `amount` off the group, at most the group's price | £1 off (1, £1.00), £5 off any 2 |

## How items are grouped

The qualifying items are lined up from most to least expensive (ties in basket order) and cut into consecutive groups; leftovers that do not make a full group pay full price. This is the usual retailer rule ("cheapest item free") and it is also the best one for the customer: 3 for 2 on items at £10, £8, £6 and £3 makes one group of £10, £8, £6 and gives the £6 item free, not the £3 one.

A percent-off with `groupSize` 1 rounds each item, so three items at £19.99 with 25% off save 3 x £5.00 = £15.00, not 25% of £59.97 = £14.99. That is what a till that discounts item by item does, and it is the only way returns of single items stay consistent.

## When promotions compete

Each item takes part in at most one deal. Promotions that share a SKU in the basket compete; for each set of competing promotions, every order of applying them is tried (each takes its groups from the items still free), and the order with the largest total saving wins. Ties go to the first order in the promotions' given order, so the result is deterministic. Trying orders beats taking the biggest single saving first: with A at £10, two of B at £10, "50% off B" and "BOGOF on A or B" each save £10 alone, so a biggest-first rule may take 50% off both Bs (£10) and leaves A alone, where BOGOF on A and one B plus 50% off the other B saves £15.

Up to six promotions may compete for the same items (720 orders); more is an error rather than a slow checkout. Promotions with no qualifying items in the basket do not count.

## Allocation back to lines

Each group's discount is split across the items in the group in proportion to their prices with `money.allocate`, so the lines' discounts add up to the deal's discount exactly and a returned item carries its fair share (`retail.refund-calculate` depends on this). The free item in a 3 for 2 is not the only line discounted; all three share the saving.

## Not modelled

Stacking (an item in two deals at once), basket-level thresholds ("£5 off when you spend £40", which is a coupon: `retail.coupon-validate`) and loyalty prices. Promotion ids must be unique.

Files

PathBytes
README.md3,066
impl/python.py7,949
impl/rust.rs11,843
impl/typescript.ts8,473
vectors.json17,128