Functional Weave
Code in Python

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.

def loyalty_points(scheme: LoyaltyScheme, spend: Money, qualifying_spend: Money, balance: int, redeem_points: int) -> 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

@dataclass(frozen=True)
class LoyaltyScheme:
    """How a scheme earns and redeems points."""

    #: points earned per earnPer of spend
    earn_points: int
    #: e.g. 1.00: "1 point per pound"
    earn_per: Money
    #: applied to the base points and again to the tier multiplier; down is usual
    earn_rounding: RoundingMode
    #: points are redeemed in multiples of this, 1 for any number
    redeem_block: int
    #: what one block is worth off the bill
    block_value: Money
    tiers: List[LoyaltyTier]

@dataclass(frozen=True)
class LoyaltyTier:
    """A tier and the multiplier it earns at."""

    name: str
    #: qualifying spend needed to reach it
    threshold: Money
    #: 10000 = 1x, 15000 = 1.5x
    multiplier_basis_points: int

@dataclass(frozen=True)
class LoyaltyOutcome:
    """The redemption, the earning and the new balance."""

    #: the highest tier whose threshold is met; null when none is
    tier: Optional[str]
    multiplier_basis_points: int
    redeemed_points: int
    #: the value of the redeemed points
    discount: Money
    #: spend less discount
    amount_payable: Money
    earned_points: int
    #: balance - redeemedPoints + earnedPoints
    new_balance: int

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

from fune.retail.loyalty_points import loyalty_points  # retail.loyalty-points@^1
impl/python.py · 72 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 Optional

from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_add import subtract_money  ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import Money, assert_same_currency, money  ← from money.amount ^1.0.0 · built alongside by fune
from .money_compare import compare_money  ← from money.compare ^1.0.0 · built alongside by fune
from .retail_loyalty_points_types import LoyaltyScheme, LoyaltyOutcome


def _whole(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def loyalty_points(
    scheme: LoyaltyScheme,
    spend: Money,
    qualifying_spend: Money,
    balance: int,
    redeem_points: int,
) -> LoyaltyOutcome:
    """Redeem points against a purchase, then earn on the amount actually paid,
    at the member's tier multiplier.
    """
    for other in [scheme.earn_per, scheme.block_value, qualifying_spend] + [t.threshold for t in scheme.tiers]:
        assert_same_currency(spend, other)
    if spend.minor < 0:
        raise ValueError("spend must not be negative, received %d" % spend.minor)
    if scheme.earn_per.minor <= 0:
        raise ValueError("earnPer must be more than 0, received %d" % scheme.earn_per.minor)
    if scheme.earn_points < 0:
        raise ValueError("earnPoints must not be negative, received %d" % scheme.earn_points)
    if scheme.redeem_block < 1:
        raise ValueError("redeemBlock must be 1 or more, received %d" % scheme.redeem_block)
    if not _whole(balance) or balance < 0:
        raise ValueError("balance must not be negative, received %r" % (balance,))
    if not _whole(redeem_points) or redeem_points < 0:
        raise ValueError("redeemPoints must not be negative, received %r" % (redeem_points,))
    if redeem_points > balance:
        raise ValueError("cannot redeem %d points from a balance of %d" % (redeem_points, balance))
    if redeem_points % scheme.redeem_block != 0:
        raise ValueError("points are redeemed in blocks of %d, received %d" % (scheme.redeem_block, redeem_points))

    discount = money(redeem_points // scheme.redeem_block * scheme.block_value.minor, spend.currency)
    if compare_money(discount, spend) > 0:
        raise ValueError(
            "redeeming %d points is worth %d, more than the spend of %d" % (redeem_points, discount.minor, spend.minor)
        )
    amount_payable = subtract_money(spend, discount)

    tier: Optional[str] = None
    multiplier_basis_points = 10000
    best: Optional[Money] = None
    for t in scheme.tiers:
        if compare_money(t.threshold, qualifying_spend) > 0:
            continue
        if best is None or compare_money(t.threshold, best) > 0:
            best = t.threshold
            tier = t.name
            multiplier_basis_points = t.multiplier_basis_points

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

    return LoyaltyOutcome(
        tier=tier,
        multiplier_basis_points=multiplier_basis_points,
        redeemed_points=redeem_points,
        discount=discount,
        amount_payable=amount_payable,
        earned_points=earned_points,
        new_balance=balance - redeem_points + earned_points,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 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.loyalty-points
Download for Python retail.loyalty-points-1.0.0-python.fune · 25,733 bytes sha256 0afda48014495885c88c53cb4df3135c2eef28dab727ac3d3c282a191bab327f

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

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