Functional Weave
Code in Python

retail.coupon-validate

Check a coupon against a basket: dates, usage limits, eligible products and minimum spend, with reason codes.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 24 tests, run in TypeScript, Python and Rust.

What it does

Decides whether a coupon code can be used on a basket, and says why not in reason codes a checkout can translate, rather than a single yes or no.

## The checks, in the order the reasons are listed

For example

  • validate_coupon(code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,…) → valid true, reasons , eligible lines 0, 1 an ordinary valid coupon on an ordinary basket
  • validate_coupon(code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,…) → valid true, reasons , eligible lines 0, 1 the first day is valid
  • validate_coupon(code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,…) → valid true, reasons , eligible lines 0, 1 the last day is valid, inclusive

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 validate_coupon(coupon: Coupon, lines: Sequence[CouponLine], customer_uses: int, on_date: str) -> CouponCheck
couponCouponthe coupon's rules
linesCouponLine[]the basket, one entry per line
customer_usesinthow many times this customer has already used the coupon
on_datedatethe date of the order, not today
returnsCouponCheck

The types it declares, generated into your project

@dataclass(frozen=True)
class Coupon:
    """The rules attached to one coupon code. A null limit is no limit."""

    code: str
    #: first day it can be used
    valid_from: Optional[str]
    #: last day it can be used, inclusive
    valid_to: Optional[str]
    minimum_spend: Optional[Money]
    #: basket: the whole basket counts; eligible: only eligible lines count
    minimum_spend_on: SpendScope
    #: redemptions allowed across all customers
    max_uses: Optional[int]
    #: redemptions so far across all customers
    times_used: int
    max_uses_per_customer: Optional[int]
    #: eligible SKUs; with categories empty too, every line is eligible
    skus: List[str]
    #: eligible categories
    categories: List[str]
    #: never eligible, even when listed above
    excluded_skus: List[str]
    #: never eligible, even when listed above
    excluded_categories: List[str]

SpendScope = Literal["basket", "eligible"]

@dataclass(frozen=True)
class CouponLine:
    """One basket line as the coupon sees it."""

    sku: str
    category: str
    #: the line's price after any line promotions
    total: Money

@dataclass(frozen=True)
class CouponCheck:
    """Whether the coupon applies, every reason it does not, and which lines it can discount."""

    valid: bool
    #: in a fixed order: not-yet-valid, expired, usage-limit-reached, customer-limit-reached, no-eligible-items, minimum-spend-not-met
    reasons: List[str]
    #: 0-based indices into lines, in basket order
    eligible_lines: List[int]

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

from fune.retail.coupon_validate import validate_coupon  # retail.coupon-validate@^1
impl/python.py · 50 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.

import re
from typing import List, Sequence

from .money_compare import compare_money  ← from money.compare ^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_coupon_validate_types import Coupon, CouponLine, CouponCheck

_ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")


def _is_eligible(coupon: Coupon, line: CouponLine) -> bool:
    open_to_all = len(coupon.skus) == 0 and len(coupon.categories) == 0
    listed = open_to_all or line.sku in coupon.skus or line.category in coupon.categories
    excluded = line.sku in coupon.excluded_skus or line.category in coupon.excluded_categories
    return listed and not excluded


def validate_coupon(coupon: Coupon, lines: Sequence[CouponLine], customer_uses: int, on_date: str) -> CouponCheck:
    """Whether a coupon can be used on a basket, with every reason it cannot.

    ISO dates compare correctly as strings.
    """
    if not isinstance(on_date, str) or not _ISO_DATE.fullmatch(on_date):
        raise ValueError('onDate must be an ISO date (YYYY-MM-DD), received "%s"' % (on_date,))
    if isinstance(customer_uses, bool) or not isinstance(customer_uses, int) or customer_uses < 0:
        raise ValueError("customerUses must not be negative, received %r" % (customer_uses,))

    eligible_lines = [i for i, line in enumerate(lines) if _is_eligible(coupon, line)]

    reasons: List[str] = []
    if coupon.valid_from is not None and on_date < coupon.valid_from:
        reasons.append("not-yet-valid")
    if coupon.valid_to is not None and on_date > coupon.valid_to:
        reasons.append("expired")
    if coupon.max_uses is not None and coupon.times_used >= coupon.max_uses:
        reasons.append("usage-limit-reached")
    if coupon.max_uses_per_customer is not None and customer_uses >= coupon.max_uses_per_customer:
        reasons.append("customer-limit-reached")
    if len(eligible_lines) == 0:
        reasons.append("no-eligible-items")
    if coupon.minimum_spend is not None:
        if coupon.minimum_spend_on == "eligible":
            counted = [lines[i].total for i in eligible_lines]
        else:
            counted = [line.total for line in lines]
        spend = sum_money(counted, coupon.minimum_spend.currency)
        if compare_money(spend, coupon.minimum_spend) < 0:
            reasons.append("minimum-spend-not-met")

    return CouponCheck(valid=len(reasons) == 0, reasons=reasons, eligible_lines=eligible_lines)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 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.coupon-validate
Download for Python retail.coupon-validate-1.0.1-python.fune · 25,703 bytes sha256 7fb911dc3277dc1b67b29dd1133550b79e7946849f1f48d4bc11aa1b79e56221

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

The whole function, every language, is one file too: retail.coupon-validate-1.0.1.fune, 33,269 bytes, sha256 4c0c6b527fd50c4a971742164fab44783c6122b6fb78fabb9a80b5a6722cb6fd. 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.coupon-validate

after — your function gets the result and the arguments, and returns the final result.

# fune: after retail.coupon-validate

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.amount in retail.coupon-validate
# fune: replace money.compare in retail.coupon-validate
# fune: replace money.sum in retail.coupon-validate

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.coupon-validate --steps.

# fune: step retail.coupon-validate 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
an ordinary valid coupon on an ordinary basket code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid true, reasons , eligible lines 0, 1
the first day is valid code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid true, reasons , eligible lines 0, 1
the last day is valid, inclusive code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid true, reasons , eligible lines 0, 1
the day before the start is not yet valid code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid false, reasons not-yet-valid, eligible lines 0, 1
the day after the end has expired code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid false, reasons expired, eligible lines 0, 1
one redemption left is still usable code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 999, max uses per customer 1, skus , categories … → valid true, reasons , eligible lines 0, 1
the global limit reached code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 1,000, max uses per customer 1, skus , categorie… → valid false, reasons usage-limit-reached, eligible lines 0, 1
this customer has already used it once code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid false, reasons customer-limit-reached, eligible lines 0, 1
spend exactly at the minimum is enough code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £23.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid true, reasons , eligible lines 0, 1
one penny under the minimum is not code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £23.01, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid false, reasons minimum-spend-not-met, eligible lines 0, 1
Show the other 14 tests
CaseArgumentsExpected
category-only coupon: minimum spend counted on eligible lines only code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on eligible, max uses 1,000, times used 10, max uses per customer 1, skus , categories… → valid false, reasons minimum-spend-not-met, eligible lines 1
category-only coupon: minimum spend counted on the whole basket code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories h… → valid true, reasons , eligible lines 1
an excluded SKU is not eligible code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid true, reasons , eligible lines 1
SKU and category lists are a union code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus TEA-1, categor… → valid true, reasons , eligible lines 0, 1
an exclusion beats an inclusion code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus TEA-1, categor… → valid false, reasons no-eligible-items, eligible lines
an empty basket has nothing eligible code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend —, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories , excl… → valid false, reasons no-eligible-items, eligible lines
an empty basket also misses a minimum spend code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → valid false, reasons no-eligible-items, minimum-spend-not-met, eligible lines
every failure is reported, in the fixed order code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £50.00, minimum spend on basket, max uses 1,000, times used 1,000, max uses per customer 1, skus , categorie… → valid false, reasons expired, usage-limit-reached, customer-limit-reached, minimum-spend-not-met, eligible lines 0, 1
null limits mean no limits code SAVE10, valid from —, valid to —, minimum spend —, minimum spend on basket, max uses —, times used 50,000, max uses per customer —, skus , categories , excluded skus , exclud… → valid true, reasons , eligible lines 0, 1
a minimum spend in another currency is an error code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend €20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → error: currency mismatch
a malformed date is an error code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → error: onDate must be an ISO date (YYYY-MM-DD)
negative customer uses is an error code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → error: customerUses must not be negative
a trailing newline is not part of an ISO date (onDate) code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → error: onDate must be an ISO date (YYYY-MM-DD)
non-ASCII digits are not an ISO date (onDate) code SAVE10, valid from 2026-09-01, valid to 2026-09-30, minimum spend £20.00, minimum spend on basket, max uses 1,000, times used 10, max uses per customer 1, skus , categories ,… → error: onDate must be an ISO date (YYYY-MM-DD)

More from the author

| reason | fails when | |---|---| | `not-yet-valid` | `onDate` is before `validFrom` | | `expired` | `onDate` is after `validTo` (the last day is inclusive) | | `usage-limit-reached` | `timesUsed` is `maxUses` or more | | `customer-limit-reached` | `customerUses` is `maxUsesPerCustomer` or more | | `no-eligible-items` | no line passes the product rules | | `minimum-spend-not-met` | the spend is below `minimumSpend` |

Every failing reason is returned, not just the first, so a checkout can say "this code expired, and it needs a £20 spend" in one go. `valid` is true exactly when `reasons` is empty. A null limit is no limit.

## Which lines are eligible

A line is eligible when its SKU is in `skus` or its category is in `categories` (either list; both empty means every line), and neither its SKU is in `excludedSkus` nor its category in `excludedCategories`. Exclusions always win: that is how "20% off homeware, excluding sale items" is written.

## Minimum spend

`minimumSpendOn` says what counts towards it: `basket` sums every line, `eligible` only the eligible ones. Retailers do both, and the difference is a common source of complaints, so the coupon has to say. Line totals should be after line promotions and before this coupon's own discount. The minimum spend and the lines must share a currency.

This function only decides eligibility. Working out the discount and spreading it across the eligible lines is `money.apply-rate` and `money.allocate`, and recording the use (incrementing `timesUsed`) is the caller's job, once the order is placed, not when the code is checked.

1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in onDate; adds tests.

Files

PathBytes
README.md1,914
impl/python.py2,382
impl/rust.rs4,960
impl/typescript.ts2,285
vectors.json14,493