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 freeapply_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 priceapply_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.
def apply_promotions(lines: Sequence[PromoLine], promotions: Sequence[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
@dataclass(frozen=True)
class PromoLine:
"""One basket line before promotions."""
sku: str
#: 0 or more
unit_price: Money
#: 1 or more
quantity: int
@dataclass(frozen=True)
class Promotion:
"""One offer. Units of any SKU in skus combine into its groups (mix and match)."""
id: str
kind: PromotionKind
#: the qualifying SKUs
skus: List[str]
#: items per deal: 1 for a plain percent off each item, 2 for BOGOF, 3 for 3-for-2 or 3 for 10.00
group_size: int
#: free-items only: the cheapest this many of each group are free; 0 otherwise
free_items: int
#: percent-off only: 2500 = 25% off each group; 0 otherwise
basis_points: int
#: amount-off: taken off each group, at most its price; group-price: what each group costs; null otherwise
amount: Optional[Money]
PromotionKind = Literal["percent-off", "amount-off", "free-items", "group-price"]
@dataclass(frozen=True)
class PricedLine:
"""A basket line after promotions."""
sku: str
quantity: int
#: unit price x quantity
gross: Money
#: this line's share of every deal it was part of
discount: Money
#: gross - discount
net: Money
@dataclass(frozen=True)
class AppliedPromotion:
"""A promotion that applied, and what it saved."""
id: str
#: how many times it applied
groups: int
discount: Money
@dataclass(frozen=True)
class PromotionResult:
"""The priced basket."""
#: in basket order
lines: List[PricedLine]
#: in the order promotions were given; promotions that did not apply are left out
applied: List[AppliedPromotion]
subtotal: Money
discount: Money
total: Money
Your code names it in one line, in the file that uses it
from fune.retail.promotion_apply import apply_promotions # 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.
from typing import Dict, List, Sequence, Tuple
from .money_allocate import allocate ← from money.allocate ^1.0.0 · built alongside by fune
from .money_amount import money ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate ← from money.apply-rate ^1.0.0 · built alongside by fune
from .money_sum import sum_money ← from money.sum ^1.0.0 · built alongside by fune
from .retail_promotion_apply_types import AppliedPromotion, PricedLine, PromoLine, Promotion, PromotionResult
_MAX_COMPETING = 6
# A unit is (line index, unit price); a group is (unit indices, discount).
_Group = Tuple[List[int], int]
def _check_promotion(p: Promotion, currency: str) -> None:
if isinstance(p.group_size, bool) or not isinstance(p.group_size, int) or p.group_size < 1:
raise ValueError('promotion "%s": groupSize must be 1 or more' % p.id)
if p.kind == "free-items":
if p.free_items < 1 or p.free_items >= p.group_size:
raise ValueError('promotion "%s": freeItems must be from 1 to groupSize - 1' % p.id)
elif p.kind == "percent-off":
if p.basis_points < 1 or p.basis_points > 10000:
raise ValueError('promotion "%s": basisPoints must be from 1 to 10000' % p.id)
elif p.kind in ("amount-off", "group-price"):
if p.amount is None:
raise ValueError('promotion "%s" needs an amount' % p.id)
if p.amount.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (currency, p.amount.currency))
if p.amount.minor < 0:
raise ValueError('promotion "%s": amount must not be negative' % p.id)
else:
raise ValueError('promotion "%s": unknown kind "%s"' % (p.id, p.kind))
def _evaluate(p: Promotion, units: List[Tuple[int, int]], skus: List[str], claimed: List[bool], currency: str) -> List[_Group]:
# 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.
pool = [i for i, (line, _) in enumerate(units) if not claimed[i] and skus[line] in p.skus]
pool.sort(key=lambda i: (-units[i][1], i))
groups: List[_Group] = []
start = 0
while start + p.group_size <= len(pool):
members = pool[start : start + p.group_size]
prices = [units[i][1] for i in members]
total = sum(prices)
if p.kind == "free-items":
discount = sum(prices[p.group_size - p.free_items :])
elif p.kind == "percent-off":
discount = apply_rate(money(total, currency), p.basis_points, "half-up").minor
elif p.kind == "amount-off":
discount = min(p.amount.minor, total)
else:
discount = max(total - p.amount.minor, 0)
if discount > 0:
groups.append((members, discount))
start += p.group_size
return groups
def _run(order, promotions, units, skus, claimed, currency):
groups: Dict[int, List[_Group]] = {}
total = 0
for index in order:
made = _evaluate(promotions[index], units, skus, claimed, currency)
for members, discount in made:
for u in members:
claimed[u] = True
total += discount
groups[index] = made
return total, groups
def _next_permutation(a: List[int]) -> bool:
i = len(a) - 2
while i >= 0 and a[i] >= a[i + 1]:
i -= 1
if i < 0:
return False
j = len(a) - 1
while a[j] <= a[i]:
j -= 1
a[i], a[j] = a[j], a[i]
a[i + 1 :] = reversed(a[i + 1 :])
return True
def apply_promotions(lines: Sequence[PromoLine], promotions: Sequence[Promotion]) -> PromotionResult:
"""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.
"""
if len(lines) == 0:
raise ValueError("a basket needs at least one line")
currency = lines[0].unit_price.currency
units: List[Tuple[int, int]] = []
skus = [line.sku for line in lines]
for index, line in enumerate(lines):
if line.unit_price.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (currency, line.unit_price.currency))
if isinstance(line.quantity, bool) or not isinstance(line.quantity, int) or line.quantity < 1:
raise ValueError("quantity must be 1 or more, received %r" % (line.quantity,))
if line.unit_price.minor < 0:
raise ValueError("unitPrice must not be negative, received %d" % line.unit_price.minor)
units.extend((index, line.unit_price.minor) for _ in range(line.quantity))
seen = set()
for p in promotions:
if p.id in seen:
raise ValueError('duplicate promotion id "%s"' % p.id)
seen.add(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.
basket_skus = set(skus)
relevant = [i for i, p in enumerate(promotions) if any(s in basket_skus for s in p.skus)]
parent = {i: i for i in relevant}
def find(i: int) -> int:
while parent[i] != i:
i = parent[i]
return i
for x in range(len(relevant)):
for y in range(x + 1, len(relevant)):
a = promotions[relevant[x]]
b = promotions[relevant[y]]
if any(s in basket_skus and s in b.skus for s in a.skus):
ra, rb = find(relevant[x]), find(relevant[y])
if ra != rb:
parent[max(ra, rb)] = min(ra, rb)
components: List[List[int]] = []
by_root: Dict[int, List[int]] = {}
for i in relevant:
root = find(i)
if root not in by_root:
by_root[root] = []
components.append(by_root[root])
by_root[root].append(i)
claimed = [False] * len(units)
chosen: Dict[int, List[_Group]] = {}
for component in components:
if len(component) > _MAX_COMPETING:
raise ValueError(
"more than %d promotions compete for the same items: %s"
% (_MAX_COMPETING, ", ".join(promotions[i].id for i in component))
)
order = list(component)
best_order = list(order)
best_total = -1
while True:
total, _ = _run(order, promotions, units, skus, list(claimed), currency)
if total > best_total:
best_total = total
best_order = list(order)
if not _next_permutation(order):
break
_, groups = _run(best_order, promotions, units, skus, claimed, currency)
chosen.update(groups)
line_discount = [0] * len(lines)
applied: List[AppliedPromotion] = []
for index, p in enumerate(promotions):
made = chosen.get(index, [])
if not made:
continue
saved = 0
for members, discount in made:
shares = allocate(money(discount, currency), [units[u][1] for u in members])
for u, share in zip(members, shares):
line_discount[units[u][0]] += share.minor
saved += discount
applied.append(AppliedPromotion(id=p.id, groups=len(made), discount=money(saved, currency)))
priced = []
for index, line in enumerate(lines):
gross = line.unit_price.minor * line.quantity
priced.append(
PricedLine(
sku=line.sku,
quantity=line.quantity,
gross=money(gross, currency),
discount=money(line_discount[index], currency),
net=money(gross - line_discount[index], currency),
)
)
subtotal = sum_money([l.gross for l in priced], currency)
discount = sum_money([l.discount for l in priced], currency)
return PromotionResult(
lines=priced,
applied=applied,
subtotal=subtotal,
discount=discount,
total=money(subtotal.minor - discount.minor, currency),
)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.promotion-apply
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./retail.promotion-apply-1.0.0-python.fune, or fetch it from a terminal with fune pull retail.promotion-apply@1.0.0:python.
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 |