Functional Weave
Code in Python

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.

def trial_end(start_date: str, trial_days: int) -> 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

@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
impl/python.py · 18 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.

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
Download for Python subscriptions.trial-end-1.0.0-python.fune · 6,548 bytes sha256 60b84a0f5113aad47f673a06c5efe85123f79956f6b1a62f413ae3d8c5411e27

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.

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