Functional Weave
Code in Rust

retail.loyalty-points

Loyalty points for a purchase: redeem in blocks, earn on what is paid, with tier multipliers.

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

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

What it does

One purchase through a points scheme: the member spends some points against the bill, pays the rest, and earns points on what they paid, at their tier's rate.

## Order of operations

For example

  • loyalty_points(earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £100.00, 0, 0) → tier Bronze, multiplier basis points 100%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 23, new balance 23 1 point per whole pound at the base tier
  • loyalty_points(earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £600.00, 0, 0) → tier Silver, multiplier basis points 125%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 28, new balance 28 Silver earns 1.25x, rounded down
  • loyalty_points(earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £1,500.00, 100, 0) → tier Gold, multiplier basis points 200%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 46, new balance 146 reaching the Gold threshold exactly gives Gold

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 loyalty_points(scheme: &LoyaltyScheme, spend: &Money, qualifying_spend: &Money, balance: i64, redeem_points: i64) -> LoyaltyOutcome
schemeLoyaltySchemethe scheme's earning, redemption and tier rules
spendMoneythis purchase, after promotions and coupons, 0 or more
qualifying_spendMoneythe spend that decides the member's tier, as the scheme defines it (say the last 12 months)
balanceintpoints held before this purchase
redeem_pointsintpoints the member asks to spend on this purchase; 0 for none
returnsLoyaltyOutcome

The types it declares, generated into your project

/// How a scheme earns and redeems points.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoyaltyScheme {
    /// points earned per earnPer of spend
    pub earn_points: i64,
    /// e.g. 1.00: "1 point per pound"
    pub earn_per: Money,
    /// applied to the base points and again to the tier multiplier; down is usual
    pub earn_rounding: String,
    /// points are redeemed in multiples of this, 1 for any number
    pub redeem_block: i64,
    /// what one block is worth off the bill
    pub block_value: Money,
    pub tiers: Vec<LoyaltyTier>,
}

/// A tier and the multiplier it earns at.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoyaltyTier {
    pub name: String,
    /// qualifying spend needed to reach it
    pub threshold: Money,
    /// 10000 = 1x, 15000 = 1.5x
    pub multiplier_basis_points: i64,
}

/// The redemption, the earning and the new balance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoyaltyOutcome {
    /// the highest tier whose threshold is met; null when none is
    pub tier: Option<String>,
    pub multiplier_basis_points: i64,
    pub redeemed_points: i64,
    /// the value of the redeemed points
    pub discount: Money,
    /// spend less discount
    pub amount_payable: Money,
    pub earned_points: i64,
    /// balance - redeemedPoints + earnedPoints
    pub new_balance: i64,
}

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

fune!(retail.loyalty-points@^1);  // then call loyalty_points(…)
impl/rust.rs · 148 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::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_add::subtract_money;  ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_compare::compare_money;  ← from money.compare ^1.0.0 · built alongside by fune

/// Redeem points against a purchase, then earn on the amount actually paid,
/// at the member's tier multiplier.
///
/// # Panics
/// Panics on mixed currencies, negative amounts, or a redemption that is not
/// whole blocks, exceeds the balance or exceeds the spend.
pub fn loyalty_points(
    scheme: &LoyaltyScheme,
    spend: &Money,
    qualifying_spend: &Money,
    balance: i64,
    redeem_points: i64,
) -> LoyaltyOutcome {
    assert_same_currency(spend, &scheme.earn_per);
    assert_same_currency(spend, &scheme.block_value);
    assert_same_currency(spend, qualifying_spend);
    for t in &scheme.tiers {
        assert_same_currency(spend, &t.threshold);
    }
    if spend.minor < 0 {
        panic!("spend must not be negative, received {}", spend.minor);
    }
    if scheme.earn_per.minor <= 0 {
        panic!("earnPer must be more than 0, received {}", scheme.earn_per.minor);
    }
    if scheme.earn_points < 0 {
        panic!("earnPoints must not be negative, received {}", scheme.earn_points);
    }
    if scheme.redeem_block < 1 {
        panic!("redeemBlock must be 1 or more, received {}", scheme.redeem_block);
    }
    if balance < 0 {
        panic!("balance must not be negative, received {}", balance);
    }
    if redeem_points < 0 {
        panic!("redeemPoints must not be negative, received {}", redeem_points);
    }
    if redeem_points > balance {
        panic!("cannot redeem {} points from a balance of {}", redeem_points, balance);
    }
    if redeem_points % scheme.redeem_block != 0 {
        panic!(
            "points are redeemed in blocks of {}, received {}",
            scheme.redeem_block, redeem_points
        );
    }

    let discount = money(
        redeem_points / scheme.redeem_block * scheme.block_value.minor,
        &spend.currency,
    );
    if compare_money(&discount, spend) > 0 {
        panic!(
            "redeeming {} points is worth {}, more than the spend of {}",
            redeem_points, discount.minor, spend.minor
        );
    }
    let amount_payable = subtract_money(spend, &discount);

    let mut tier: Option<String> = None;
    let mut multiplier_basis_points = 10000;
    let mut best: Option<Money> = None;
    for t in &scheme.tiers {
        if compare_money(&t.threshold, qualifying_spend) > 0 {
            continue;
        }
        let better = match &best {
            None => true,
            Some(b) => compare_money(&t.threshold, b) > 0,
        };
        if better {
            best = Some(t.threshold.clone());
            tier = Some(t.name.clone());
            multiplier_basis_points = t.multiplier_basis_points;
        }
    }

    let base = round_div(
        amount_payable.minor * scheme.earn_points,
        scheme.earn_per.minor,
        &scheme.earn_rounding,
    );
    let earned_points = round_div(base * multiplier_basis_points, 10000, &scheme.earn_rounding);

    LoyaltyOutcome {
        tier,
        multiplier_basis_points,
        redeemed_points: redeem_points,
        discount,
        amount_payable,
        earned_points,
        new_balance: balance - redeem_points + earned_points,
    }
}

pub fn loyalty_scheme_from_value(v: &Value) -> LoyaltyScheme {
    LoyaltyScheme {
        earn_points: v.get("earnPoints").as_i64(),
        earn_per: money_from_value(v.get("earnPer")),
        earn_rounding: v.get("earnRounding").as_str().to_string(),
        redeem_block: v.get("redeemBlock").as_i64(),
        block_value: money_from_value(v.get("blockValue")),
        tiers: v
            .get("tiers")
            .as_arr()
            .iter()
            .map(|t| LoyaltyTier {
                name: t.get("name").as_str().to_string(),
                threshold: money_from_value(t.get("threshold")),
                multiplier_basis_points: t.get("multiplierBasisPoints").as_i64(),
            })
            .collect(),
    }
}

pub fn loyalty_outcome_to_value(o: &LoyaltyOutcome) -> Value {
    Value::obj(vec![
        (
            "tier",
            match &o.tier {
                Some(name) => Value::str(name),
                None => Value::Null,
            },
        ),
        ("multiplierBasisPoints", Value::Int(o.multiplier_basis_points)),
        ("redeemedPoints", Value::Int(o.redeemed_points)),
        ("discount", money_to_value(&o.discount)),
        ("amountPayable", money_to_value(&o.amount_payable)),
        ("earnedPoints", Value::Int(o.earned_points)),
        ("newBalance", Value::Int(o.new_balance)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    loyalty_outcome_to_value(&loyalty_points(
        &loyalty_scheme_from_value(&args[0]),
        &money_from_value(&args[1]),
        &money_from_value(&args[2]),
        args[3].as_i64(),
        args[4].as_i64(),
    ))
}

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.loyalty-points
Download for Rust retail.loyalty-points-1.0.0-rust.fune · 27,697 bytes sha256 69d4bdce586657701327bc678c31428c655d56a139789a3c2fff98b3c5910970

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

The whole function, every language, is one file too: retail.loyalty-points-1.0.0.fune, 33,957 bytes, sha256 4e1450deacf6621b1f6f757b77e6042937b79b1a75eca021eacdb3dd2afa4307. 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.loyalty-points

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

// fune: after retail.loyalty-points

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 retail.loyalty-points
// fune: replace money.add in retail.loyalty-points
// fune: replace money.amount in retail.loyalty-points
// fune: replace money.compare in retail.loyalty-points

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.loyalty-points --steps.

// fune: step retail.loyalty-points 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
1 point per whole pound at the base tier earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £100.00, 0, 0 → tier Bronze, multiplier basis points 100%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 23, new balance 23
Silver earns 1.25x, rounded down earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £600.00, 0, 0 → tier Silver, multiplier basis points 125%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 28, new balance 28
reaching the Gold threshold exactly gives Gold earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £1,500.00, 100, 0 → tier Gold, multiplier basis points 200%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 46, new balance 146
one penny under the Gold threshold is Silver earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £1,499.99, 0, 0 → tier Silver, multiplier basis points 125%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 28, new balance 28
double points doubles whole-pound points: 9.99 earns 18, not 19 earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £9.99, £2,000.00, 0, 0 → tier Gold, multiplier basis points 200%, redeemed points 0, discount £0.00, amount payable £9.99, earned points 18, new balance 18
redeemed points are not earned on earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £23.49, £100.00, 450, 300 → tier Bronze, multiplier basis points 100%, redeemed points 300, discount £3.00, amount payable £20.49, earned points 20, new balance 170
points can pay for the whole purchase, earning nothing earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £3.00, £100.00, 300, 300 → tier Bronze, multiplier basis points 100%, redeemed points 300, discount £3.00, amount payable £0.00, earned points 0, new balance 0
no tiers at all is 1x and no tier name earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers , £23.49, £100.00, 5, 0 → tier —, multiplier basis points 100%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 23, new balance 28
below every threshold is 1x and no tier name earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×2, £23.49, £100.00, 0, 0 → tier —, multiplier basis points 100%, redeemed points 0, discount £0.00, amount payable £23.49, earned points 23, new balance 23
half-up earning rounds 2.50 to 3 points earn points 1, earn per £1.00, earn rounding half-up, redeem block 150, block value £1.50, tiers ×3, £2.50, £0.00, 0, 0 → tier Bronze, multiplier basis points 100%, redeemed points 0, discount £0.00, amount payable £2.50, earned points 3, new balance 3
Show the other 9 tests
CaseArgumentsExpected
4 points per pound, redeemable one at a time at 1p each earn points 4, earn per £1.00, earn rounding down, redeem block 1, block value £0.01, tiers , £12.37, £0.00, 57, 57 → tier —, multiplier basis points 100%, redeemed points 57, discount £0.57, amount payable £11.80, earned points 47, new balance 47
points worth half a penny: 500 points for 2.50 earn points 1, earn per £1.00, earn rounding down, redeem block 500, block value £2.50, tiers , £10.00, £0.00, 1,200, 1,000 → tier —, multiplier basis points 100%, redeemed points 1,000, discount £5.00, amount payable £5.00, earned points 5, new balance 205
a zero spend earns nothing earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £0.00, £100.00, 10, 0 → tier Bronze, multiplier basis points 100%, redeemed points 0, discount £0.00, amount payable £0.00, earned points 0, new balance 10
tiers listed out of order still pick the highest reached earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £10.00, £600.00, 0, 0 → tier Silver, multiplier basis points 125%, redeemed points 0, discount £0.00, amount payable £10.00, earned points 12, new balance 12
redeeming more value than the spend is an error earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £1.00, £0.00, 150, 150 → error: redeeming 150 points is worth 150, more than the spend of 100
a redemption that is not whole blocks is an error earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £20.00, £0.00, 450, 100 → error: points are redeemed in blocks of 150
redeeming more points than the balance is an error earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, £20.00, £0.00, 450, 600 → error: cannot redeem 600 points from a balance of 450
a spend in another currency is an error earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, €20.00, €0.00, 0, 0 → error: currency mismatch
a negative spend is an error earn points 1, earn per £1.00, earn rounding down, redeem block 150, block value £1.50, tiers ×3, -£1.00, £0.00, 0, 0 → error: spend must not be negative

More from the author

1. **Redeem.** `redeemPoints` must be a multiple of `redeemBlock` (schemes such as "150 points = £1.50" redeem in blocks) and no more than the balance. The discount is `redeemPoints / redeemBlock x blockValue`, and it may not exceed the spend. 2. **Pay.** `amountPayable = spend - discount`. 3. **Earn on what was paid.** Points are not earned on the part of the bill paid with points, which is how most UK schemes work and stops points earning points. - base = `amountPayable x earnPoints / earnPer`, rounded with `earnRounding` (`down` gives "1 point per whole pound"). - earned = `base x multiplierBasisPoints / 10000`, rounded the same way. 4. **Balance.** `balance - redeemedPoints + earnedPoints`.

Rounding twice is deliberate. A "double points for Gold" scheme doubles the points you would have earned, so £9.99 at 1 point per whole pound is 9 points, doubled to 18. Rounding once (9.99 x 2 = 19.98, so 19) gives the customer a point the scheme's own terms do not.

## Tiers

The tier is the one with the highest `threshold` at or below `qualifyingSpend` (the first listed wins a tie). With no tier reached, `tier` is null and the multiplier is 1x. What counts as qualifying spend (this year, the last 12 months, including this purchase or not) is the scheme's rule, so the caller passes it in.

## Errors

Redeeming more points than the balance, a number that is not a whole number of blocks, or more value than the spend is an error, not a silent cap: a till that quietly redeems fewer points than the customer asked for is a complaint. All amounts must be in one currency. Refunds are not handled here; reverse the original transaction's points instead.

Files

PathBytes
README.md1,902
impl/python.py3,141
impl/rust.rs5,007
impl/typescript.ts2,911
vectors.json14,086