Voucher Expiration Rule Types — Developer Guide

Blank 14/8/2026 08:41 - 14/8/2026 08:41
Developers
Voucher Expiration Rule Types — Developer Guide

Voucher Expiration Rule Types

A pluggable way to compute when a voucher expires, written in JS and configured per account — for calendar logic that a fixed day-count or fixed date can't express (end of month, end of quarter, end of financial year, and so on).

Files involved: VoucherExpiryRuleType.java, JsVoucherExpiryRuleType.java, voucher-expiry-rules.xml, Nashorn / Queries repo.

1. Overview

Every VoucherType has an expiry modenone, days (a fixed period after issue), date (a fixed calendar date), or calc. This guide is about the last one: a custom, JS-authored VoucherExpiryRuleType that works out the expiry date itself, per voucher, at allocation time.

It mirrors an existing mechanism already used for points balances (PointsExpiryRuleType / JsPointsExpiryRuleType) — if you've built one of those before, this will feel familiar.

You'll touch two files: a plain .js file holding your calcExpiry function, and a voucher-expiry-rules.xml file declaring the rule and which function to call. Both live in the account's Queries repo — see Registering it below.

2. Where it fits

When a voucher is allocated, Voucher.changeStatus() asks VoucherManager for the expiry date:

Voucher.changeStatus()VoucherManager.calculateExpiry()VoucherType.getEffectiveExpiryMode()your calcExpiry(…)

calculateExpiry() only reaches your rule when the voucher type's effective mode is calc and an expirationCalculatorId is set. If your rule type can't be found (wrong id, or removed from the account's config), the voucher gets no expiry rather than silently falling back to a fixed-days/fixed-date value — those belong to the other, mutually-exclusive modes, not to yours as a fallback.

3. The interface

VoucherExpiryRuleType is the Java-side contract. You won't usually implement this directly — JsVoucherExpiryRuleType (next section) already implements it and hands your JS function the same four arguments.

// io/milton/cloud/server/rewards/VoucherExpiryRuleType.java
public interface VoucherExpiryRuleType {

    String getId();
    String getTitle();
    List<ExtraField> getFields();

    /**
     * @param voucher    the voucher being allocated - not yet persisted with its new
     *                   status/expiry, but voucher.getVoucherType() is available
     * @param now        the date the allocation is happening - use this, not wall-clock
     *                   time, so your rule is consistent and testable
     * @param ruleParams this voucher type's own configured params (see "Config fields")
     * @param session
     * @return the expiry date to assign, or null for no expiry
     */
    Date calcExpiry(Voucher voucher, Date now, Map<String, String> ruleParams, Session session);
}

4. Writing a rule

A JS rule is one function taking (voucher, now, ruleParams) and returning a Date, or a falsy value for no expiry. It runs on the account's Nashorn engine — the same one that runs custom points rules and record matchers — so ruleParams is a real java.util.Map (.get("name"), not ruleParams.name).

Worked example — expires N months after allocation, on the last day of that month:

// endOfMonth.js
// ruleParams: { monthsAhead: "1" }
function calcExpiry(voucher, now, ruleParams) {
    var Calendar = Java.type("java.util.Calendar");
    var monthsAhead = parseInt(ruleParams.get("monthsAhead") || "1", 10);

    var cal = Calendar.getInstance();
    cal.setTime(now);
    cal.add(Calendar.MONTH, monthsAhead);
    cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH));

    return cal.getTime();
}

Issued 13 August with monthsAhead = 2 → expires 31 October. Leave monthsAhead unset and it defaults to 1 → expires on the last day of next month. Verified directly against the real Nashorn engine and this exact function: 15 Jan 2026 + 2 months → 31 Mar 2026; default (1 month) → 28 Feb 2026.

5. Registering it

Two files in the account's Queries repo (open it via Files → queries in the admin UI — it's a plain repository despite the name, browsed through the ordinary file manager).

  1. Add your .js file, and reference it from controllers.xml — Every path listed as a <source> gets loaded into the account's script engine, so calcExpiry becomes callable by name.
  2. Declare the rule type in voucher-expiry-rules.xml — Root element <voucher-expiry-rules>, one <voucher-expiry-rule> per rule.
  3. Select it on the voucher type — The "Calculated" card on the voucher type's Expiry section lists every rule declared this way.
<!-- controllers.xml -->
<controllers>
    <source>endOfMonth.js</source>
</controllers>
<!-- voucher-expiry-rules.xml -->
<voucher-expiry-rules>
    <expiryRuleTypes>
        <voucher-expiry-rule>
            <id>end-of-month</id>
            <title>End of month</title>
            <fields>
                <field>
                    <name>monthsAhead</name>
                    <required>true</required>
                    <title>Months ahead</title>
                    <text>Number of months after allocation the voucher expires, on the last day of that month</text>
                </field>
            </fields>
            <processFn>calcExpiry</processFn>
        </voucher-expiry-rule>
    </expiryRuleTypes>
</voucher-expiry-rules>

id is what gets stored on the voucher type (expirationCalculatorId) — keep it stable once anything is using it.

6. Config fields

Each <field> becomes one input on the "Calculated" card once your rule is selected, and one entry in the ruleParams map your JS function receives (string-keyed, string-valued — parse numbers yourself, as endOfMonth.js does with parseInt).

ElementMeaning
nameThe key your JS reads via ruleParams.get(…).
requiredCosmetic only — nothing currently blocks saving if it's left blank. Handle a missing value in your own JS.
titleField label on the form, and the label the live summary strip reads back when describing your rule's params.
textHelp text rendered under the input.

7. Admin UI

Voucher type → Expiry now shows four mutually-exclusive cards — No expiry, Fixed period after issue, Fixed date, Calculated — only the selected one expanded. Every rule declared in voucher-expiry-rules.xml for the account appears in the Calculated card's Rule dropdown; picking one reloads its own fields inline, and a plain-English summary underneath restates the current choice (turning amber if a required field looks empty).

Nothing else to build on your side for this — the dropdown, per-rule fields, and summary are all generic against whatever getFields() returns.

8. Testing it

Two levels, roughly cheapest-first:

  • The JS in isolation — load your file into a plain Nashorn engine and call calcExpiry directly with a mock now/ruleParams. No server needed; this is how endOfMonth.js above was verified.
  • End to end — set the voucher type's expiry to your rule, allocate a voucher, confirm Voucher.getExpiryDate() matches what you expect for a few different now values (month boundaries, leap years, etc.).

9. Gotchas

A missing rule means "no expiry," not a fallback. If expirationCalculatorId points at a rule that no longer exists, calculateExpiry() returns null rather than falling through to validForDays/staticExpiryDate — those are a different mode's own parameters, not a safety net for yours.

Legacy voucher types infer their mode. Any VoucherType saved before this mechanism existed has a blank expiryMode; getEffectiveExpiryMode() infers it from whichever of the old fields is set. You don't need to migrate anything, but be aware the "effective" mode can differ from the raw stored one.

Not app-registered. Unlike points rules, there's no marketplace-app registration tier for voucher expiry rules — only the account's own Queries repo. These are meant to be account-specific, not distributed.