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 basketvalidate_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 validvalidate_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
| coupon | Coupon | the coupon's rules |
| lines | CouponLine[] | the basket, one entry per line |
| customer_uses | int | how many times this customer has already used the coupon |
| on_date | date | the date of the order, not today |
| returns | CouponCheck |
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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,822 |
| impl/python.py | 2,371 |
| impl/rust.rs | 4,960 |
| impl/typescript.ts | 2,285 |
| vectors.json | 13,264 |