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
loyaltyPoints(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 tierloyaltyPoints(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 downloyaltyPoints(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.
export function loyaltyPoints(scheme: LoyaltyScheme, spend: Money, qualifyingSpend: Money, balance: number, redeemPoints: number): LoyaltyOutcome
| scheme | LoyaltyScheme | the scheme's earning, redemption and tier rules |
| spend | Money | this purchase, after promotions and coupons, 0 or more |
| qualifyingSpend | Money | the spend that decides the member's tier, as the scheme defines it (say the last 12 months) |
| balance | int | points held before this purchase |
| redeemPoints | int | points the member asks to spend on this purchase; 0 for none |
| returns | LoyaltyOutcome |
The types it declares, generated into your project
/** How a scheme earns and redeems points. */
export interface LoyaltyScheme {
/** points earned per earnPer of spend */
readonly earnPoints: number;
/** e.g. 1.00: "1 point per pound" */
readonly earnPer: Money;
/** applied to the base points and again to the tier multiplier; down is usual */
readonly earnRounding: RoundingMode;
/** points are redeemed in multiples of this, 1 for any number */
readonly redeemBlock: number;
/** what one block is worth off the bill */
readonly blockValue: Money;
readonly tiers: readonly LoyaltyTier[];
}
/** A tier and the multiplier it earns at. */
export interface LoyaltyTier {
readonly name: string;
/** qualifying spend needed to reach it */
readonly threshold: Money;
/** 10000 = 1x, 15000 = 1.5x */
readonly multiplierBasisPoints: number;
}
/** The redemption, the earning and the new balance. */
export interface LoyaltyOutcome {
/** the highest tier whose threshold is met; null when none is */
readonly tier: string | null;
readonly multiplierBasisPoints: number;
readonly redeemedPoints: number;
/** the value of the redeemed points */
readonly discount: Money;
/** spend less discount */
readonly amountPayable: Money;
readonly earnedPoints: number;
/** balance - redeemedPoints + earnedPoints */
readonly newBalance: number;
}
Your code names it in one line, in the file that uses it
import { loyaltyPoints } from "#fune/retail.loyalty-points@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { subtractMoney } from "./money_add.ts"; ← from money.add ^1.0.0 · built alongside by fune
import { type Money, assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { compareMoney } from "./money_compare.ts"; ← from money.compare ^1.0.0 · built alongside by fune
import { type LoyaltyScheme, type LoyaltyOutcome } from "./retail_loyalty_points_types.ts";
/**
* Redeem points against a purchase, then earn on the amount actually paid, at
* the member's tier multiplier.
*/
export function loyaltyPoints(
scheme: LoyaltyScheme,
spend: Money,
qualifyingSpend: Money,
balance: number,
redeemPoints: number,
): LoyaltyOutcome {
for (const other of [scheme.earnPer, scheme.blockValue, qualifyingSpend, ...scheme.tiers.map((t) => t.threshold)]) {
assertSameCurrency(spend, other);
}
if (spend.minor < 0) throw new RangeError(`spend must not be negative, received ${spend.minor}`);
if (scheme.earnPer.minor <= 0) throw new RangeError(`earnPer must be more than 0, received ${scheme.earnPer.minor}`);
if (scheme.earnPoints < 0) throw new RangeError(`earnPoints must not be negative, received ${scheme.earnPoints}`);
if (scheme.redeemBlock < 1) throw new RangeError(`redeemBlock must be 1 or more, received ${scheme.redeemBlock}`);
if (!Number.isInteger(balance) || balance < 0) throw new RangeError(`balance must not be negative, received ${balance}`);
if (!Number.isInteger(redeemPoints) || redeemPoints < 0) {
throw new RangeError(`redeemPoints must not be negative, received ${redeemPoints}`);
}
if (redeemPoints > balance) {
throw new RangeError(`cannot redeem ${redeemPoints} points from a balance of ${balance}`);
}
if (redeemPoints % scheme.redeemBlock !== 0) {
throw new RangeError(`points are redeemed in blocks of ${scheme.redeemBlock}, received ${redeemPoints}`);
}
const discount = money((redeemPoints / scheme.redeemBlock) * scheme.blockValue.minor, spend.currency);
if (compareMoney(discount, spend) > 0) {
throw new RangeError(
`redeeming ${redeemPoints} points is worth ${discount.minor}, more than the spend of ${spend.minor}`,
);
}
const amountPayable = subtractMoney(spend, discount);
let tier: string | null = null;
let multiplierBasisPoints = 10000;
let best: Money | null = null;
for (const t of scheme.tiers) {
if (compareMoney(t.threshold, qualifyingSpend) > 0) continue;
if (best === null || compareMoney(t.threshold, best) > 0) {
best = t.threshold;
tier = t.name;
multiplierBasisPoints = t.multiplierBasisPoints;
}
}
const base = roundDiv(amountPayable.minor * scheme.earnPoints, scheme.earnPer.minor, scheme.earnRounding);
const earnedPoints = roundDiv(base * multiplierBasisPoints, 10000, scheme.earnRounding);
return {
tier,
multiplierBasisPoints,
redeemedPoints: redeemPoints,
discount,
amountPayable,
earnedPoints,
newBalance: balance - redeemPoints + earnedPoints,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./retail.loyalty-points-1.0.0-typescript.fune, or fetch it from a terminal with fune pull retail.loyalty-points@1.0.0:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,902 |
| impl/python.py | 3,141 |
| impl/rust.rs | 5,007 |
| impl/typescript.ts | 2,911 |
| vectors.json | 14,086 |