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.
def trial_end(start_date: str, trial_days: int) -> 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
@dataclass(frozen=True)
class TrialPeriod:
"""The trial's last free day and the day billing starts."""
#: the last free day, inclusive; null when trialDays is 0
last_trial_day: Optional[str]
#: the day the first payment is taken: the day after the last free day
first_charge_date: str
Your code names it in one line, in the file that uses it
from fune.subscriptions.trial_end import trial_end # subscriptions.trial-end@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from .dates_add_days import add_days ← from dates.add-days ^1.0.0 · built alongside by fune
from .subscriptions_trial_end_types import TrialPeriod
def trial_end(start_date: str, trial_days: int) -> TrialPeriod:
"""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.
"""
if isinstance(trial_days, bool) or not isinstance(trial_days, int):
raise TypeError("trial days must be an integer, received %r" % (trial_days,))
if trial_days < 0:
raise ValueError("trial days must not be negative, received %d" % (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".
first_charge_date = add_days(start_date, trial_days)
last_trial_day = None if trial_days == 0 else add_days(start_date, trial_days - 1)
return TrialPeriod(last_trial_day=last_trial_day, first_charge_date=first_charge_date)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python 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 subscriptions.trial-end
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./subscriptions.trial-end-1.0.0-python.fune, or fetch it from a terminal with fune pull subscriptions.trial-end@1.0.0:python.
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 |