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
applyPromotions(lines ×1, promotions ×1)→ lines ×1, applied ×1, subtotal £6.00, discount £3.00, total £3.00 BOGOF on four: two freeapplyPromotions(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 priceapplyPromotions(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.
export function applyPromotions(lines: readonly PromoLine[], promotions: readonly Promotion[]): PromotionResult
| lines | PromoLine[] | the basket; at least one line, one currency |
| promotions | Promotion[] | the offers that may apply; order only breaks ties |
| returns | PromotionResult |
The types it declares, generated into your project
/** One basket line before promotions. */
export interface PromoLine {
readonly sku: string;
/** 0 or more */
readonly unitPrice: Money;
/** 1 or more */
readonly quantity: number;
}
/** One offer. Units of any SKU in skus combine into its groups (mix and match). */
export interface Promotion {
readonly id: string;
readonly kind: PromotionKind;
/** the qualifying SKUs */
readonly skus: readonly 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 */
readonly groupSize: number;
/** free-items only: the cheapest this many of each group are free; 0 otherwise */
readonly freeItems: number;
/** percent-off only: 2500 = 25% off each group; 0 otherwise */
readonly basisPoints: number;
/** amount-off: taken off each group, at most its price; group-price: what each group costs; null otherwise */
readonly amount: Money | null;
}
export type PromotionKind = "percent-off" | "amount-off" | "free-items" | "group-price";
/** A basket line after promotions. */
export interface PricedLine {
readonly sku: string;
readonly quantity: number;
/** unit price x quantity */
readonly gross: Money;
/** this line's share of every deal it was part of */
readonly discount: Money;
/** gross - discount */
readonly net: Money;
}
/** A promotion that applied, and what it saved. */
export interface AppliedPromotion {
readonly id: string;
/** how many times it applied */
readonly groups: number;
readonly discount: Money;
}
/** The priced basket. */
export interface PromotionResult {
/** in basket order */
readonly lines: readonly PricedLine[];
/** in the order promotions were given; promotions that did not apply are left out */
readonly applied: readonly AppliedPromotion[];
readonly subtotal: Money;
readonly discount: Money;
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { applyPromotions } from "#fune/retail.promotion-apply@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { allocate } from "./money_allocate.ts"; ← from money.allocate ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts"; ← from money.apply-rate ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { type PromoLine, type Promotion, type PromotionResult, type PricedLine, type AppliedPromotion } from "./retail_promotion_apply_types.ts";
const MAX_COMPETING = 6;
interface Unit {
line: number;
price: number;
}
interface Group {
units: number[];
discount: number;
}
function checkPromotion(p: Promotion, currency: string): void {
if (!Number.isInteger(p.groupSize) || p.groupSize < 1) {
throw new RangeError(`promotion "${p.id}": groupSize must be 1 or more`);
}
if (p.kind === "free-items") {
if (p.freeItems < 1 || p.freeItems >= p.groupSize) {
throw new RangeError(`promotion "${p.id}": freeItems must be from 1 to groupSize - 1`);
}
} else if (p.kind === "percent-off") {
if (p.basisPoints < 1 || p.basisPoints > 10000) {
throw new RangeError(`promotion "${p.id}": basisPoints must be from 1 to 10000`);
}
} else if (p.kind === "amount-off" || p.kind === "group-price") {
if (p.amount === null) throw new RangeError(`promotion "${p.id}" needs an amount`);
if (p.amount.currency !== currency) {
throw new RangeError(`currency mismatch: ${currency} and ${p.amount.currency}`);
}
if (p.amount.minor < 0) throw new RangeError(`promotion "${p.id}": amount must not be negative`);
} else {
throw new RangeError(`promotion "${p.id}": unknown kind "${p.kind}"`);
}
}
// The groups one promotion makes from the units nobody has claimed yet: the
// qualifying units from dearest to cheapest (ties in basket order), cut into
// consecutive groups of groupSize. Groups that would save nothing are skipped.
function evaluate(p: Promotion, units: Unit[], skus: string[], claimed: boolean[], currency: string): Group[] {
const pool: number[] = [];
units.forEach((u, i) => {
if (!claimed[i] && p.skus.includes(skus[u.line])) pool.push(i);
});
pool.sort((a, b) => units[b].price - units[a].price || a - b);
const groups: Group[] = [];
for (let start = 0; start + p.groupSize <= pool.length; start += p.groupSize) {
const members = pool.slice(start, start + p.groupSize);
const prices = members.map((i) => units[i].price);
const total = prices.reduce((s, x) => s + x, 0);
let discount = 0;
if (p.kind === "free-items") {
discount = prices.slice(p.groupSize - p.freeItems).reduce((s, x) => s + x, 0);
} else if (p.kind === "percent-off") {
discount = applyRate(money(total, currency), p.basisPoints, "half-up").minor;
} else if (p.kind === "amount-off") {
discount = Math.min((p.amount as Money).minor, total);
} else {
discount = Math.max(total - (p.amount as Money).minor, 0);
}
if (discount > 0) groups.push({ units: members, discount });
}
return groups;
}
// Apply promotions in the given order, each claiming the units it groups.
function run(
order: number[],
promotions: readonly Promotion[],
units: Unit[],
skus: string[],
claimed: boolean[],
currency: string,
): { total: number; groups: Map<number, Group[]> } {
const groups = new Map<number, Group[]>();
let total = 0;
for (const index of order) {
const made = evaluate(promotions[index], units, skus, claimed, currency);
for (const g of made) {
for (const u of g.units) claimed[u] = true;
total += g.discount;
}
groups.set(index, made);
}
return { total, groups };
}
// Next permutation in lexicographic order, in place; false after the last.
function nextPermutation(a: number[]): boolean {
let i = a.length - 2;
while (i >= 0 && a[i] >= a[i + 1]) i--;
if (i < 0) return false;
let j = a.length - 1;
while (a[j] <= a[i]) j--;
[a[i], a[j]] = [a[j], a[i]];
for (let l = i + 1, r = a.length - 1; l < r; l++, r--) [a[l], a[r]] = [a[r], a[l]];
return true;
}
/**
* 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.
*/
export function applyPromotions(lines: readonly PromoLine[], promotions: readonly Promotion[]): PromotionResult {
if (lines.length === 0) throw new RangeError("a basket needs at least one line");
const currency = lines[0].unitPrice.currency;
const units: Unit[] = [];
const skus = lines.map((l) => l.sku);
lines.forEach((line, index) => {
if (line.unitPrice.currency !== currency) {
throw new RangeError(`currency mismatch: ${currency} and ${line.unitPrice.currency}`);
}
if (!Number.isInteger(line.quantity) || line.quantity < 1) {
throw new RangeError(`quantity must be 1 or more, received ${line.quantity}`);
}
if (line.unitPrice.minor < 0) throw new RangeError(`unitPrice must not be negative, received ${line.unitPrice.minor}`);
for (let q = 0; q < line.quantity; q++) units.push({ line: index, price: line.unitPrice.minor });
});
const seen = new Set<string>();
for (const p of promotions) {
if (seen.has(p.id)) throw new RangeError(`duplicate promotion id "${p.id}"`);
seen.add(p.id);
checkPromotion(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.
const basketSkus = new Set(skus);
const relevant = promotions.map((_, i) => i).filter((i) => promotions[i].skus.some((s) => basketSkus.has(s)));
const parent = new Map<number, number>(relevant.map((i) => [i, i]));
const find = (i: number): number => {
while (parent.get(i) !== i) i = parent.get(i) as number;
return i;
};
for (let x = 0; x < relevant.length; x++) {
for (let y = x + 1; y < relevant.length; y++) {
const a = promotions[relevant[x]];
const b = promotions[relevant[y]];
if (a.skus.some((s) => basketSkus.has(s) && b.skus.includes(s))) {
const ra = find(relevant[x]);
const rb = find(relevant[y]);
if (ra !== rb) parent.set(Math.max(ra, rb), Math.min(ra, rb));
}
}
}
const components: number[][] = [];
const byRoot = new Map<number, number[]>();
for (const i of relevant) {
const root = find(i);
if (!byRoot.has(root)) {
byRoot.set(root, []);
components.push(byRoot.get(root) as number[]);
}
(byRoot.get(root) as number[]).push(i);
}
const claimed = units.map(() => false);
const chosen = new Map<number, Group[]>();
for (const component of components) {
if (component.length > MAX_COMPETING) {
throw new RangeError(
`more than ${MAX_COMPETING} promotions compete for the same items: ${component.map((i) => promotions[i].id).join(", ")}`,
);
}
const order = [...component];
let bestOrder = [...order];
let bestTotal = -1;
do {
const { total } = run(order, promotions, units, skus, [...claimed], currency);
if (total > bestTotal) {
bestTotal = total;
bestOrder = [...order];
}
} while (nextPermutation(order));
const { groups } = run(bestOrder, promotions, units, skus, claimed, currency);
for (const [index, made] of groups) chosen.set(index, made);
}
const lineDiscount = lines.map(() => 0);
const applied: AppliedPromotion[] = [];
promotions.forEach((p, index) => {
const made = chosen.get(index) ?? [];
if (made.length === 0) return;
let saved = 0;
for (const g of made) {
const shares = allocate(
money(g.discount, currency),
g.units.map((u) => units[u].price),
);
g.units.forEach((u, k) => {
lineDiscount[units[u].line] += shares[k].minor;
});
saved += g.discount;
}
applied.push({ id: p.id, groups: made.length, discount: money(saved, currency) });
});
const priced: PricedLine[] = lines.map((line, index) => {
const gross = line.unitPrice.minor * line.quantity;
return {
sku: line.sku,
quantity: line.quantity,
gross: money(gross, currency),
discount: money(lineDiscount[index], currency),
net: money(gross - lineDiscount[index], currency),
};
});
const subtotal = sumMoney(priced.map((l) => l.gross), currency);
const discount = sumMoney(priced.map((l) => l.discount), currency);
return {
lines: priced,
applied,
subtotal,
discount,
total: money(subtotal.minor - discount.minor, currency),
};
}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.promotion-apply
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./retail.promotion-apply-1.0.0-typescript.fune, or fetch it from a terminal with fune pull retail.promotion-apply@1.0.0:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 3,066 |
| impl/python.py | 7,949 |
| impl/rust.rs | 11,843 |
| impl/typescript.ts | 8,473 |
| vectors.json | 17,128 |