Functional Weave
Code in Rust

retail.coupon-validate

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

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 22 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.

pub fn validate_coupon(coupon: &Coupon, lines: &[CouponLine], customer_uses: i64, 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

/// The rules attached to one coupon code. A null limit is no limit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Coupon {
    pub code: String,
    /// first day it can be used
    pub valid_from: Option<String>,
    /// last day it can be used, inclusive
    pub valid_to: Option<String>,
    pub minimum_spend: Option<Money>,
    /// basket: the whole basket counts; eligible: only eligible lines count
    pub minimum_spend_on: String,
    /// redemptions allowed across all customers
    pub max_uses: Option<i64>,
    /// redemptions so far across all customers
    pub times_used: i64,
    pub max_uses_per_customer: Option<i64>,
    /// eligible SKUs; with categories empty too, every line is eligible
    pub skus: Vec<String>,
    /// eligible categories
    pub categories: Vec<String>,
    /// never eligible, even when listed above
    pub excluded_skus: Vec<String>,
    /// never eligible, even when listed above
    pub excluded_categories: Vec<String>,
}

// SpendScope is a string in Rust, one of: "basket", "eligible".
// Parameters take it as &str and results hold it as String.

/// One basket line as the coupon sees it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CouponLine {
    pub sku: String,
    pub category: String,
    /// the line's price after any line promotions
    pub total: Money,
}

/// Whether the coupon applies, every reason it does not, and which lines it can discount.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CouponCheck {
    pub valid: bool,
    /// in a fixed order: not-yet-valid, expired, usage-limit-reached, customer-limit-reached, no-eligible-items, minimum-spend-not-met
    pub reasons: Vec<String>,
    /// 0-based indices into lines, in basket order
    pub eligible_lines: Vec<i64>,
}

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

fune!(retail.coupon-validate@^1);  // then call validate_coupon(…)
impl/rust.rs · 153 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::money_amount::{money_from_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_compare::compare_money;  ← from money.compare ^1.0.0 · built alongside by fune
use super::money_sum::sum_money;  ← from money.sum ^1.0.0 · built alongside by fune

fn is_iso_date(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() == 10
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes
            .iter()
            .enumerate()
            .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}

fn is_eligible(coupon: &Coupon, line: &CouponLine) -> bool {
    let open = coupon.skus.is_empty() && coupon.categories.is_empty();
    let listed = open || coupon.skus.contains(&line.sku) || coupon.categories.contains(&line.category);
    let excluded = coupon.excluded_skus.contains(&line.sku)
        || coupon.excluded_categories.contains(&line.category);
    listed && !excluded
}

/// Whether a coupon can be used on a basket, with every reason it cannot.
/// ISO dates compare correctly as strings.
///
/// # Panics
/// Panics on a malformed date, negative customer uses, or mixed currencies.
pub fn validate_coupon(coupon: &Coupon, lines: &[CouponLine], customer_uses: i64, on_date: &str) -> CouponCheck {
    if !is_iso_date(on_date) {
        panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date);
    }
    if customer_uses < 0 {
        panic!("customerUses must not be negative, received {}", customer_uses);
    }

    let eligible_lines: Vec<i64> = lines
        .iter()
        .enumerate()
        .filter(|(_, line)| is_eligible(coupon, line))
        .map(|(i, _)| i as i64)
        .collect();

    let mut reasons: Vec<String> = Vec::new();
    if let Some(from) = &coupon.valid_from {
        if on_date < from.as_str() {
            reasons.push("not-yet-valid".to_string());
        }
    }
    if let Some(to) = &coupon.valid_to {
        if on_date > to.as_str() {
            reasons.push("expired".to_string());
        }
    }
    if let Some(max) = coupon.max_uses {
        if coupon.times_used >= max {
            reasons.push("usage-limit-reached".to_string());
        }
    }
    if let Some(max) = coupon.max_uses_per_customer {
        if customer_uses >= max {
            reasons.push("customer-limit-reached".to_string());
        }
    }
    if eligible_lines.is_empty() {
        reasons.push("no-eligible-items".to_string());
    }
    if let Some(minimum) = &coupon.minimum_spend {
        let counted: Vec<Money> = if coupon.minimum_spend_on == "eligible" {
            eligible_lines.iter().map(|i| lines[*i as usize].total.clone()).collect()
        } else {
            lines.iter().map(|l| l.total.clone()).collect()
        };
        let spend = sum_money(&counted, &minimum.currency);
        if compare_money(&spend, minimum) < 0 {
            reasons.push("minimum-spend-not-met".to_string());
        }
    }

    CouponCheck {
        valid: reasons.is_empty(),
        reasons,
        eligible_lines,
    }
}

fn strings(v: &Value) -> Vec<String> {
    v.as_arr().iter().map(|s| s.as_str().to_string()).collect()
}

fn opt_str(v: &Value) -> Option<String> {
    if v.is_null() {
        None
    } else {
        Some(v.as_str().to_string())
    }
}

fn opt_int(v: &Value) -> Option<i64> {
    if v.is_null() {
        None
    } else {
        Some(v.as_i64())
    }
}

pub fn coupon_from_value(v: &Value) -> Coupon {
    Coupon {
        code: v.get("code").as_str().to_string(),
        valid_from: opt_str(v.get("validFrom")),
        valid_to: opt_str(v.get("validTo")),
        minimum_spend: if v.get("minimumSpend").is_null() {
            None
        } else {
            Some(money_from_value(v.get("minimumSpend")))
        },
        minimum_spend_on: v.get("minimumSpendOn").as_str().to_string(),
        max_uses: opt_int(v.get("maxUses")),
        times_used: v.get("timesUsed").as_i64(),
        max_uses_per_customer: opt_int(v.get("maxUsesPerCustomer")),
        skus: strings(v.get("skus")),
        categories: strings(v.get("categories")),
        excluded_skus: strings(v.get("excludedSkus")),
        excluded_categories: strings(v.get("excludedCategories")),
    }
}

pub fn coupon_line_from_value(v: &Value) -> CouponLine {
    CouponLine {
        sku: v.get("sku").as_str().to_string(),
        category: v.get("category").as_str().to_string(),
        total: money_from_value(v.get("total")),
    }
}

pub fn coupon_check_to_value(c: &CouponCheck) -> Value {
    Value::obj(vec![
        ("valid", Value::Bool(c.valid)),
        ("reasons", Value::Arr(c.reasons.iter().map(|r| Value::str(r)).collect())),
        ("eligibleLines", Value::Arr(c.eligible_lines.iter().map(|i| Value::Int(*i)).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let lines: Vec<CouponLine> = args[1].as_arr().iter().map(coupon_line_from_value).collect();
    coupon_check_to_value(&validate_coupon(
        &coupon_from_value(&args[0]),
        &lines,
        args[2].as_i64(),
        args[3].as_str(),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add retail.coupon-validate
Download for Rust retail.coupon-validate-1.0.0-rust.fune · 26,924 bytes sha256 d7fea8c9bce85176ebbf7cc9af3e03e77b5d46f45297c1b124006969359fea7d

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

The whole function, every language, is one file too: retail.coupon-validate-1.0.0.fune, 31,771 bytes, sha256 2f3b1751f83b86484da40c127ba5c07ab7e3d3451f7d11c8dee60ec1e953c6a6. 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 12 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

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.

Files

PathBytes
README.md1,822
impl/python.py2,371
impl/rust.rs4,960
impl/typescript.ts2,285
vectors.json13,264