Functional Weave
Code in Python

retail.promotion-apply@1.0.0

README.md

3,066 bytes · view raw

# retail.promotion-apply

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

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.