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 periodprorate_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-upprorate_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.
pub fn prorate_change(old_price: &Money, new_price: &Money, period_start: &str, period_end: &str, change_date: &str, mode: &str) -> PlanChangeProration
| old_price | Money | the full-period price of the plan being left, as it was billed (after any discount) |
| new_price | Money | the full-period price of the plan being moved to, in the same currency |
| period_start | date | the first day of the current billing period |
| period_end | date | the first day of the next period; the current one runs up to but not including it |
| change_date | date | the first day on the new plan, from periodStart to periodEnd inclusive |
| mode | RoundingMode | how each of the two lines rounds to a whole minor unit |
| returns | PlanChangeProration |
The type it declares, generated into your project
/// The two proration lines and what they come to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanChangeProration {
/// days in the billing period
pub total_days: i64,
/// days from changeDate to periodEnd, the time being re-priced
pub remaining_days: i64,
/// unused time on the old plan; zero or negative
pub credit: Money,
/// remaining time on the new plan
pub charge: Money,
/// charge plus credit: owed by the customer, or owed to them when negative
pub net: Money,
}
Your code names it in one line, in the file that uses it
fune!(subscriptions.proration@^1); // then call prorate_change(…)
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_days_between::days_between; ← from dates.days-between ^1.0.0 · built alongside by fune
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_add::add_money; ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics if the period is empty or reversed, the change date is outside it,
/// the prices are in different currencies, or the mode is unknown.
pub fn prorate_change(
old_price: &Money,
new_price: &Money,
period_start: &str,
period_end: &str,
change_date: &str,
mode: &str,
) -> PlanChangeProration {
let total_days = days_between(period_start, period_end);
if total_days <= 0 {
panic!(
"period end must be after period start, received {} to {}",
period_start, period_end
);
}
let elapsed = days_between(period_start, change_date);
if elapsed < 0 || elapsed > total_days {
panic!(
"change date must be within the billing period, received {} for {} to {}",
change_date, period_start, period_end
);
}
assert_same_currency(old_price, new_price);
let remaining_days = total_days - elapsed;
let credit = money(
-round_div(old_price.minor * remaining_days, total_days, mode),
&old_price.currency,
);
let charge = money(
round_div(new_price.minor * remaining_days, total_days, mode),
&new_price.currency,
);
let net = add_money(&charge, &credit);
PlanChangeProration {
total_days,
remaining_days,
credit,
charge,
net,
}
}
pub fn plan_change_proration_to_value(p: &PlanChangeProration) -> Value {
Value::obj(vec![
("totalDays", Value::Int(p.total_days)),
("remainingDays", Value::Int(p.remaining_days)),
("credit", money_to_value(&p.credit)),
("charge", money_to_value(&p.charge)),
("net", money_to_value(&p.net)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
plan_change_proration_to_value(&prorate_change(
&money_from_value(&args[0]),
&money_from_value(&args[1]),
args[2].as_str(),
args[3].as_str(),
args[4].as_str(),
args[5].as_str(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, 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.proration
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./subscriptions.proration-1.0.0-rust.fune, or fetch it from a terminal with fune pull subscriptions.proration@1.0.0:rust.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,225 |
| impl/python.py | 1,650 |
| impl/rust.rs | 2,470 |
| impl/typescript.ts | 1,610 |
| vectors.json | 5,889 |