Functional Weave
Code in Rust

subscriptions.trial-end

When a free trial ends and the first payment is due, from the trial's start date and length in days.

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

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

What it does

A trial of N days that starts on day S covers S and the N - 1 days after it, so the last free day is S + N - 1 and the first payment is taken on S + N. A 14-day trial starting 1 January is free up to and including 14 January and is charged on 15 January. Both dates come back, because a sign-up page prints the first ("your trial ends on 14 January") and the billing system schedules the second, and deriving one from the other is where the off-by-one creeps in.

Trials are counted in days, never in months: a 30-day trial from 31 January ends on 1 March in 2026 (2 March's charge), not on "28 February". If the offer really is "one month free", the first charge is dates.add-months from the start date and this capability is not the one to use.

For example

  • trial_end(2026-01-01, 14) → last trial day 2026-01-14, first charge date 2026-01-15 a 14 day trial from 1 January is free to the 14th and charged on the 15th
  • trial_end(2026-02-15, 14) → last trial day 2026-02-28, first charge date 2026-03-01 a trial crossing the end of February in an ordinary year
  • trial_end(2024-02-15, 14) → last trial day 2024-02-28, first charge date 2024-02-29 the same trial in a leap year is charged on 29 February

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 trial_end(start_date: &str, trial_days: i64) -> TrialPeriod
start_datedatethe first day of the trial, ISO; the customer has the service from this day
trial_daysintlength of the trial in whole days, 0 or more; 0 means no trial
returnsTrialPeriod

The type it declares, generated into your project

/// The trial's last free day and the day billing starts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrialPeriod {
    /// the last free day, inclusive; null when trialDays is 0
    pub last_trial_day: Option<String>,
    /// the day the first payment is taken: the day after the last free day
    pub first_charge_date: String,
}

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

fune!(subscriptions.trial-end@^1);  // then call trial_end(…)
impl/rust.rs · 43 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::dates_add_days::add_days;  ← from dates.add-days ^1.0.0 · built alongside by fune

/// The last free day and the first charge date of a trial of `trial_days` days
/// starting on `start_date`. The start day counts as day one, so the last free
/// day is start + trial_days - 1 and the charge is the day after it.
///
/// # Panics
/// Panics if `trial_days` is negative or the start date is not a real ISO date.
pub fn trial_end(start_date: &str, trial_days: i64) -> TrialPeriod {
    if trial_days < 0 {
        panic!("trial days must not be negative, received {}", trial_days);
    }
    // add_days validates the start date even when there is no trial, so a
    // malformed date never slips through as the "first charge date".
    let first_charge_date = add_days(start_date, trial_days);
    let last_trial_day = if trial_days == 0 {
        None
    } else {
        Some(add_days(start_date, trial_days - 1))
    };
    TrialPeriod {
        last_trial_day,
        first_charge_date,
    }
}

pub fn trial_period_to_value(period: &TrialPeriod) -> Value {
    Value::obj(vec![
        (
            "lastTrialDay",
            match &period.last_trial_day {
                Some(day) => Value::str(day),
                None => Value::Null,
            },
        ),
        ("firstChargeDate", Value::str(&period.first_charge_date)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    trial_period_to_value(&trial_end(args[0].as_str(), args[1].as_i64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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 subscriptions.trial-end
Download for Rust subscriptions.trial-end-1.0.0-rust.fune · 6,971 bytes sha256 7a772097ffd09a01adba6d88862ee23564c8313b6f36a09002fbda28afd1a4cd

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

The whole function, every language, is one file too: subscriptions.trial-end-1.0.0.fune, 9,102 bytes, sha256 34e5fca082f1dd914bf792c858bd5b8e161ad3cf9dfe5681ace9b206478a7b3e. 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 subscriptions.trial-end

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

// fune: after subscriptions.trial-end

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 dates.add-days in subscriptions.trial-end

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 subscriptions.trial-end --steps.

// fune: step subscriptions.trial-end 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
a 14 day trial from 1 January is free to the 14th and charged on the 15th 2026-01-01, 14 → last trial day 2026-01-14, first charge date 2026-01-15
a trial crossing the end of February in an ordinary year 2026-02-15, 14 → last trial day 2026-02-28, first charge date 2026-03-01
the same trial in a leap year is charged on 29 February 2024-02-15, 14 → last trial day 2024-02-28, first charge date 2024-02-29
a trial ending on New Year's Eve is charged in the next year 2026-12-25, 7 → last trial day 2026-12-31, first charge date 2027-01-01
30 days from 31 January is 1 March, not 'a month later' on 28 February 2026-01-31, 30 → last trial day 2026-03-01, first charge date 2026-03-02
a one day trial is free on the start day only 2026-09-23, 1 → last trial day 2026-09-23, first charge date 2026-09-24
no trial: nothing is free and the charge is on the start date 2026-09-23, 0 → last trial day —, first charge date 2026-09-23
a 365 day trial that does not cross a 29 February 2024-03-01, 365 → last trial day 2025-02-28, first charge date 2025-03-01
a 365 day trial that crosses 29 February ends a day short of the anniversary 2023-03-01, 365 → last trial day 2024-02-28, first charge date 2024-02-29
a negative trial length is an error 2026-01-01, -1 → error: trial days must not be negative
Show the other 2 tests
CaseArgumentsExpected
an impossible start date is an error, even with no trial 2026-02-30, 0 → error: is not a real calendar date
a start date that is not ISO is an error 01/02/2026, 14 → error: is not an ISO date

More from the author

A trial of 0 days is no trial: lastTrialDay is null and the first charge is on the start date itself. A negative length is an error, as is an impossible start date (2026-02-30), which the dates.add-days kernel refuses to parse.

This works on calendar dates. A trial that starts at 23:59 in one time zone and is billed at midnight in another is a time-zone decision for the caller; convert to the billing account's local date first.

Files

PathBytes
README.md1,208
impl/python.py1,051
impl/rust.rs1,457
impl/typescript.ts989
vectors.json2,047