Functional Weave
Code in Python

subscriptions.proration

Mid-cycle plan change: credit for unused time on the old plan and charge for the rest of the period on the new one.

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

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

What it does

What Stripe calls "create prorations": when a customer changes plan part of the way through a billing period, they get a credit for the time they have paid for and will no longer use on the old plan, and a charge for the rest of the period on the new one. Stripe's own example: moving from a 10 USD to a 20 USD monthly plan halfway through is a 5 USD credit and a 10 USD charge, 5 USD net.

credit = -(oldPrice x remainingDays / totalDays) charge = newPrice x remainingDays / totalDays

For example

  • prorate_change($10.00, $20.00, 2026-04-01, 2026-05-01, 2026-04-16, half-up) → total days 30, remaining days 15, credit -$5.00, charge $10.00, net $5.00 Stripe's example: 10 to 20 a month, halfway through a 30 day period
  • prorate_change(£9.99, £19.99, 2026-01-01, 2026-02-01, 2026-01-11, half-up) → total days 31, remaining days 21, credit -£6.77, charge £13.54, net £6.77 upgrade 10 days into a 31 day January, half-up
  • prorate_change(£9.99, £19.99, 2026-01-01, 2026-02-01, 2026-01-11, down) → total days 31, remaining days 21, credit -£6.76, charge £13.54, net £6.78 the same upgrade rounded down: both lines shrink, so the net moves

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 prorate_change(old_price: Money, new_price: Money, period_start: str, period_end: str, change_date: str, mode: RoundingMode) -> PlanChangeProration
old_priceMoneythe full-period price of the plan being left, as it was billed (after any discount)
new_priceMoneythe full-period price of the plan being moved to, in the same currency
period_startdatethe first day of the current billing period
period_enddatethe first day of the next period; the current one runs up to but not including it
change_datedatethe first day on the new plan, from periodStart to periodEnd inclusive
modeRoundingModehow each of the two lines rounds to a whole minor unit
returnsPlanChangeProration

The type it declares, generated into your project

@dataclass(frozen=True)
class PlanChangeProration:
    """The two proration lines and what they come to."""

    #: days in the billing period
    total_days: int
    #: days from changeDate to periodEnd, the time being re-priced
    remaining_days: int
    #: unused time on the old plan; zero or negative
    credit: Money
    #: remaining time on the new plan
    charge: Money
    #: charge plus credit: owed by the customer, or owed to them when negative
    net: Money

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

from fune.subscriptions.proration import prorate_change  # subscriptions.proration@^1
impl/python.py · 42 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_days_between import days_between  ← from dates.days-between ^1.0.0 · built alongside by fune
from .math_round_div import RoundingMode, round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_add import add_money  ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import Money, assert_same_currency, money  ← from money.amount ^1.0.0 · built alongside by fune
from .subscriptions_proration_types import PlanChangeProration


def prorate_change(
    old_price: Money,
    new_price: Money,
    period_start: str,
    period_end: str,
    change_date: str,
    mode: RoundingMode,
) -> PlanChangeProration:
    """Stripe-style proration for a mid-cycle plan change, by whole days: a
    credit for the unused remainder of the old plan and a charge for the same
    remainder on the new plan, each rounded once from the exact fraction.
    """
    total_days = days_between(period_start, period_end)
    if total_days <= 0:
        raise ValueError(
            "period end must be after period start, received %s to %s" % (period_start, period_end)
        )
    elapsed = days_between(period_start, change_date)
    if elapsed < 0 or elapsed > total_days:
        raise ValueError(
            "change date must be within the billing period, received %s for %s to %s"
            % (change_date, period_start, period_end)
        )
    assert_same_currency(old_price, new_price)
    remaining_days = total_days - elapsed

    credit = money(-round_div(old_price.minor * remaining_days, total_days, mode), old_price.currency)
    charge = money(round_div(new_price.minor * remaining_days, total_days, mode), new_price.currency)
    return PlanChangeProration(
        total_days=total_days,
        remaining_days=remaining_days,
        credit=credit,
        charge=charge,
        net=add_money(charge, credit),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, 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.proration
Download for Python subscriptions.proration-1.0.0-python.fune · 13,885 bytes sha256 fec8fe96df780f9748bcbf9b0f329864874e682f1241a9b1db80cf883c773109

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

The whole function, every language, is one file too: subscriptions.proration-1.0.0.fune, 18,130 bytes, sha256 189c16e33e151e6292e99ef2c233360a38e28ac036826ef9729369aa5a430d98. 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.proration

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

# fune: after subscriptions.proration

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.days-between in subscriptions.proration
# fune: replace math.round-div in subscriptions.proration
# fune: replace money.add in subscriptions.proration
# fune: replace money.amount in subscriptions.proration

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.proration --steps.

# fune: step subscriptions.proration 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
Stripe's example: 10 to 20 a month, halfway through a 30 day period $10.00, $20.00, 2026-04-01, 2026-05-01, 2026-04-16, half-up → total days 30, remaining days 15, credit -$5.00, charge $10.00, net $5.00
upgrade 10 days into a 31 day January, half-up £9.99, £19.99, 2026-01-01, 2026-02-01, 2026-01-11, half-up → total days 31, remaining days 21, credit -£6.77, charge £13.54, net £6.77
the same upgrade rounded down: both lines shrink, so the net moves £9.99, £19.99, 2026-01-01, 2026-02-01, 2026-01-11, down → total days 31, remaining days 21, credit -£6.76, charge £13.54, net £6.78
the same upgrade rounded up: both lines grow away from zero £9.99, £19.99, 2026-01-01, 2026-02-01, 2026-01-11, up → total days 31, remaining days 21, credit -£6.77, charge £13.55, net £6.78
a downgrade halfway through February leaves the customer in credit £50.00, £20.00, 2026-02-01, 2026-03-01, 2026-02-15, half-up → total days 28, remaining days 14, credit -£25.00, charge £10.00, net -£15.00
a change on the first day of the period re-prices all of it £30.00, £60.00, 2026-06-01, 2026-07-01, 2026-06-01, half-up → total days 30, remaining days 30, credit -£30.00, charge £60.00, net £30.00
a change on the period end re-prices nothing £30.00, £60.00, 2026-06-01, 2026-07-01, 2026-07-01, half-up → total days 30, remaining days 0, credit £0.00, charge £0.00, net £0.00
an annual plan changed on 1 July of a leap year: 184 of 366 days remain £120.00, £240.00, 2024-01-01, 2025-01-01, 2024-07-01, half-up → total days 366, remaining days 184, credit -£60.33, charge £120.66, net £60.33
a half-penny credit rounds to even under half-even £0.05, £0.15, 2026-01-01, 2026-01-03, 2026-01-02, half-even → total days 2, remaining days 1, credit -£0.02, charge £0.08, net £0.06
the same half-penny credit rounds away from zero under half-up £0.05, £0.15, 2026-01-01, 2026-01-03, 2026-01-02, half-up → total days 2, remaining days 1, credit -£0.03, charge £0.08, net £0.05
Show the other 7 tests
CaseArgumentsExpected
yen has no minor unit, so the credit rounds to whole yen ¥1,000, ¥3,000, 2026-04-01, 2026-05-01, 2026-04-21, half-up → total days 30, remaining days 10, credit -¥333, charge ¥1,000, net ¥667
moving to a free plan is a credit and no charge £49.00, £0.00, 2026-09-01, 2026-10-01, 2026-09-21, half-up → total days 30, remaining days 10, credit -£16.33, charge £0.00, net -£16.33
prices in different currencies are an error £10.00, €20.00, 2026-04-01, 2026-05-01, 2026-04-16, half-up → error: currency mismatch
a change date before the period is an error £10.00, £20.00, 2026-04-01, 2026-05-01, 2026-03-31, half-up → error: change date must be within the billing period
a change date after the period end is an error £10.00, £20.00, 2026-04-01, 2026-05-01, 2026-05-02, half-up → error: change date must be within the billing period
an empty period is an error £10.00, £20.00, 2026-04-01, 2026-04-01, 2026-04-01, half-up → error: period end must be after period start
an unknown rounding mode is an error £10.00, £20.00, 2026-04-01, 2026-05-01, 2026-04-16, nearest → error: unknown rounding mode

More from the author

The two lines are returned separately because they are printed separately ("Unused time on Basic", "Remaining time on Pro"), and each is rounded once, by the `mode` the caller chooses, using math.round-div on the exact fraction. The mode applies to each line's size, so `up` rounds the credit and the charge both away from zero. `net` is the sum of the two rounded lines, which is what the invoice will add up to; it is not re-rounded from the exact difference.

Days, not seconds. Stripe prorates to the second by default; this capability works on calendar dates, which is what most invoices print and what can be reproduced by hand. The period is half-open: `periodStart` is its first day and `periodEnd` is the first day of the next period (Stripe's `current_period_end`), so a January period is 2026-01-01 to 2026-02-01, 31 days. A change on `periodStart` re-prices the whole period, and a change on `periodEnd` re-prices nothing (both lines are zero).

The prices are full-period prices as billed, after any recurring discount - Stripe likewise prorates from the discounted price. If the old and new plans have different intervals (monthly to annual) this is the wrong tool: that change usually starts a new billing period instead, so it is a credit for the old plan's unused time (finance.proration) and a full charge for the new one.

Errors: a period whose end is not after its start, a change date outside the period, prices in different currencies, and an unknown rounding mode. finance.proration is the related split of one amount into used and unused parts that always add back up; this is the plan-change calculation built on the same idea, with two prices and an explicit rounding mode.

Files

PathBytes
README.md2,225
impl/python.py1,650
impl/rust.rs2,470
impl/typescript.ts1,610
vectors.json5,889