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
validateCoupon(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 basketvalidateCoupon(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 validvalidateCoupon(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.
export function validateCoupon(coupon: Coupon, lines: readonly CouponLine[], customerUses: number, onDate: string): CouponCheck
| coupon | Coupon | the coupon's rules |
| lines | CouponLine[] | the basket, one entry per line |
| customerUses | int | how many times this customer has already used the coupon |
| onDate | 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. */
export interface Coupon {
readonly code: string;
/** first day it can be used */
readonly validFrom: string | null;
/** last day it can be used, inclusive */
readonly validTo: string | null;
readonly minimumSpend: Money | null;
/** basket: the whole basket counts; eligible: only eligible lines count */
readonly minimumSpendOn: SpendScope;
/** redemptions allowed across all customers */
readonly maxUses: number | null;
/** redemptions so far across all customers */
readonly timesUsed: number;
readonly maxUsesPerCustomer: number | null;
/** eligible SKUs; with categories empty too, every line is eligible */
readonly skus: readonly string[];
/** eligible categories */
readonly categories: readonly string[];
/** never eligible, even when listed above */
readonly excludedSkus: readonly string[];
/** never eligible, even when listed above */
readonly excludedCategories: readonly string[];
}
export type SpendScope = "basket" | "eligible";
/** One basket line as the coupon sees it. */
export interface CouponLine {
readonly sku: string;
readonly category: string;
/** the line's price after any line promotions */
readonly total: Money;
}
/** Whether the coupon applies, every reason it does not, and which lines it can discount. */
export interface CouponCheck {
readonly valid: boolean;
/** in a fixed order: not-yet-valid, expired, usage-limit-reached, customer-limit-reached, no-eligible-items, minimum-spend-not-met */
readonly reasons: readonly string[];
/** 0-based indices into lines, in basket order */
readonly eligibleLines: readonly number[];
}
Your code names it in one line, in the file that uses it
import { validateCoupon } from "#fune/retail.coupon-validate@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { compareMoney } from "./money_compare.ts"; ← from money.compare ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { type Coupon, type CouponLine, type CouponCheck } from "./retail_coupon_validate_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function isEligible(coupon: Coupon, line: CouponLine): boolean {
const open = coupon.skus.length === 0 && coupon.categories.length === 0;
const listed = open || coupon.skus.includes(line.sku) || coupon.categories.includes(line.category);
const excluded = coupon.excludedSkus.includes(line.sku) || coupon.excludedCategories.includes(line.category);
return listed && !excluded;
}
/**
* Whether a coupon can be used on a basket, with every reason it cannot.
* ISO dates compare correctly as strings.
*/
export function validateCoupon(
coupon: Coupon,
lines: readonly CouponLine[],
customerUses: number,
onDate: string,
): CouponCheck {
if (!ISO_DATE.test(onDate)) throw new RangeError(`onDate must be an ISO date (YYYY-MM-DD), received "${onDate}"`);
if (!Number.isInteger(customerUses) || customerUses < 0) {
throw new RangeError(`customerUses must not be negative, received ${customerUses}`);
}
const eligibleLines: number[] = [];
lines.forEach((line, index) => {
if (isEligible(coupon, line)) eligibleLines.push(index);
});
const reasons: string[] = [];
if (coupon.validFrom !== null && onDate < coupon.validFrom) reasons.push("not-yet-valid");
if (coupon.validTo !== null && onDate > coupon.validTo) reasons.push("expired");
if (coupon.maxUses !== null && coupon.timesUsed >= coupon.maxUses) reasons.push("usage-limit-reached");
if (coupon.maxUsesPerCustomer !== null && customerUses >= coupon.maxUsesPerCustomer) {
reasons.push("customer-limit-reached");
}
if (eligibleLines.length === 0) reasons.push("no-eligible-items");
if (coupon.minimumSpend !== null) {
const counted: Money[] =
coupon.minimumSpendOn === "eligible" ? eligibleLines.map((i) => lines[i].total) : lines.map((l) => l.total);
const spend = sumMoney(counted, coupon.minimumSpend.currency);
if (compareMoney(spend, coupon.minimumSpend) < 0) reasons.push("minimum-spend-not-met");
}
return { valid: reasons.length === 0, reasons, eligibleLines };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript 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
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./retail.coupon-validate-1.0.0-typescript.fune, or fetch it from a terminal with fune pull retail.coupon-validate@1.0.0:typescript.
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 |