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 15thtrial_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 yeartrial_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_date | date | the first day of the trial, ISO; the customer has the service from this day |
| trial_days | int | length of the trial in whole days, 0 or more; 0 means no trial |
| returns | TrialPeriod |
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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,208 |
| impl/python.py | 1,051 |
| impl/rust.rs | 1,457 |
| impl/typescript.ts | 989 |
| vectors.json | 2,047 |