subscriptions.invoice
A subscription renewal invoice: plan, add-ons and usage, coupon discounts by billing period, and VAT.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
Builds the invoice for one renewal of a subscription. The plan, add-on and usage lines are ordinary invoice lines (finance.invoice.calculate's `InvoiceLine`, per-line discounts included); what this capability adds is the subscription part - which coupons apply in this billing period, how much each takes off, and how that reduction is taxed - and it hands the result to finance.invoice.calculate for VAT and totals. It invents no arithmetic of its own: line totals, percentages, splitting and VAT are all existing capabilities.
**Which coupons apply.** Coupons follow Stripe's three durations, counted in billing periods of this subscription: `once` applies only in `startPeriod`; `repeating` applies in `durationPeriods` periods starting at `startPeriod` (so a 3-period coupon starting in period 2 covers periods 2, 3 and 4, not 5); `forever` applies from `startPeriod` on. Stripe counts repeating coupons in months; for a monthly plan that is the same thing.
For example
renewal_invoice(description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, , 5, GB, 2026-09-01)→ currency GBP, lines ×3, subtotal £91.50, tax total £18.30, total £109.80, tax breakdown ×1 plan, seats and usage with no couponsrenewal_invoice(description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 5, GB, 2026-09-01)→ currency GBP, lines ×4, subtotal £73.20, tax total £14.64, total £87.84, tax breakdown ×1 a 20% forever coupon becomes one discount line, taxed like the lines it reducesrenewal_invoice(description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 4, GB, 2026-09-01)→ currency GBP, lines ×4, subtotal £82.35, tax total £16.47, total £98.82, tax breakdown ×1 a 3-period coupon from period 2 still applies in period 4
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 renewal_invoice(plan: &InvoiceLine, add_ons: &[InvoiceLine], usage: &[InvoiceLine], discounts: &[Discount], period_number: i64, jurisdiction: &str, invoice_date: &str) -> Invoice
| plan | InvoiceLine | the plan's line for this period |
| add_ons | InvoiceLine[] | seats, extras and one-off lines such as proration credits (negative prices) |
| usage | InvoiceLine[] | metered usage lines, e.g. priced by subscriptions.usage-tiered |
| discounts | Discount[] | coupons on the subscription, applied in this order |
| period_number | int | which billing period of the subscription this invoice is for, 1-based |
| jurisdiction | string | VAT jurisdiction, as finance.tax.vat-rate takes it: GB |
| invoice_date | date | the tax point, which decides the VAT rates |
| returns | Invoice |
The types it declares, generated into your project
// DiscountDuration is a string in Rust, one of: "once", "repeating", "forever".
// Parameters take it as &str and results hold it as String.
/// A coupon on the subscription, shaped like a Stripe coupon.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Discount {
/// printed on the discount line
pub description: String,
/// percent off; 2000 is 20% off. Exactly one of basisPoints and amountOff
pub basis_points: Option<i64>,
/// a fixed amount off each invoice it applies to
pub amount_off: Option<Money>,
pub duration: String,
/// repeating only: how many billing periods it lasts
pub duration_periods: Option<i64>,
/// the first billing period it applies to, 1-based
pub start_period: i64,
}
Your code names it in one line, in the file that uses it
fune!(subscriptions.invoice@^1); // then call renewal_invoice(…)
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::finance_invoice_calculate::{calculate_invoice, invoice_line_from_value, invoice_to_value, Invoice, InvoiceLine}; ← from finance.invoice.calculate ^1.0.0 · built alongside by fune
use super::finance_invoice_line_total::line_total; ← from finance.invoice.line-total ^1.0.0 · built alongside by fune
use super::money_allocate::allocate; ← from money.allocate ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value}; ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate; ← from money.apply-rate ^1.0.0 · built alongside by fune
fn check_discount(d: &Discount, currency: &str) {
if d.basis_points.is_none() == d.amount_off.is_none() {
panic!(
"a discount needs exactly one of basisPoints and amountOff: \"{}\"",
d.description
);
}
if let Some(bp) = d.basis_points {
if !(0..=10000).contains(&bp) {
panic!("discount basis points must be between 0 and 10000, received {}", bp);
}
}
if let Some(off) = &d.amount_off {
assert_same_currency(&money(0, currency), off);
if off.minor < 0 {
panic!("discount amount must not be negative, received {}", off.minor);
}
}
if d.start_period < 1 {
panic!("discount start period must be 1 or more, received {}", d.start_period);
}
match d.duration.as_str() {
"repeating" => {
if d.duration_periods.map_or(true, |n| n < 1) {
panic!(
"a repeating discount needs durationPeriods of 1 or more, received {:?}",
d.duration_periods
);
}
}
"once" | "forever" => {
if d.duration_periods.is_some() {
panic!(
"durationPeriods applies only to a repeating discount: \"{}\"",
d.description
);
}
}
other => panic!(
"unknown discount duration \"{}\": expected once, repeating or forever",
other
),
}
}
fn applies(d: &Discount, period: i64) -> bool {
match d.duration.as_str() {
"once" => period == d.start_period,
"repeating" => period >= d.start_period && period < d.start_period + d.duration_periods.unwrap_or(0),
_ => period >= d.start_period,
}
}
/// The renewal invoice for one billing period: plan, add-ons and usage, less
/// the coupons in force in that period, taxed per line by
/// finance::invoice::calculate. Each coupon becomes negative lines, one per
/// VAT category, so the reduction is taxed at the rate of what it reduces.
///
/// # Panics
/// Panics on a period below 1, a malformed coupon, or anything
/// `calculate_invoice` refuses.
pub fn renewal_invoice(
plan: &InvoiceLine,
add_ons: &[InvoiceLine],
usage: &[InvoiceLine],
discounts: &[Discount],
period_number: i64,
jurisdiction: &str,
invoice_date: &str,
) -> Invoice {
if period_number < 1 {
panic!("period number must be 1 or more, received {}", period_number);
}
let currency = plan.unit_price.currency.clone();
let mut items: Vec<InvoiceLine> = vec![plan.clone()];
items.extend(add_ons.iter().cloned());
items.extend(usage.iter().cloned());
let mut categories: Vec<String> = Vec::new();
let mut category_net: Vec<i64> = Vec::new();
let mut running: i64 = 0;
for item in &items {
let net = line_total(&item.unit_price, item.quantity, item.discount_basis_points);
assert_same_currency(&money(0, ¤cy), &net);
let k = match categories.iter().position(|c| *c == item.tax_category) {
Some(k) => k,
None => {
categories.push(item.tax_category.clone());
category_net.push(0);
categories.len() - 1
}
};
category_net[k] += net.minor;
running += net.minor;
}
// Credit-only categories take no share of a discount.
let weights: Vec<i64> = category_net.iter().map(|&n| n.max(0)).collect();
let mut discount_lines: Vec<InvoiceLine> = Vec::new();
for d in discounts {
check_discount(d, ¤cy);
if !applies(d, period_number) || running <= 0 {
continue;
}
let amount = match (d.basis_points, &d.amount_off) {
(Some(bp), _) => apply_rate(&money(running, ¤cy), bp, "half-up").minor,
(None, Some(off)) => off.minor.min(running),
(None, None) => 0,
};
if amount == 0 {
continue;
}
running -= amount;
for (k, share) in allocate(&money(amount, ¤cy), &weights).iter().enumerate() {
if share.minor != 0 {
discount_lines.push(InvoiceLine {
description: d.description.clone(),
unit_price: money(-share.minor, ¤cy),
quantity: 1,
discount_basis_points: 0,
tax_category: categories[k].clone(),
});
}
}
}
items.extend(discount_lines);
calculate_invoice(&items, jurisdiction, invoice_date)
}
pub fn discount_from_value(v: &Value) -> Discount {
Discount {
description: v.get("description").as_str().to_string(),
basis_points: if v.get("basisPoints").is_null() { None } else { Some(v.get("basisPoints").as_i64()) },
amount_off: if v.get("amountOff").is_null() { None } else { Some(money_from_value(v.get("amountOff"))) },
duration: v.get("duration").as_str().to_string(),
duration_periods: if v.get("durationPeriods").is_null() { None } else { Some(v.get("durationPeriods").as_i64()) },
start_period: v.get("startPeriod").as_i64(),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
let add_ons: Vec<InvoiceLine> = args[1].as_arr().iter().map(invoice_line_from_value).collect();
let usage: Vec<InvoiceLine> = args[2].as_arr().iter().map(invoice_line_from_value).collect();
let discounts: Vec<Discount> = args[3].as_arr().iter().map(discount_from_value).collect();
invoice_to_value(&renewal_invoice(
&invoice_line_from_value(&args[0]),
&add_ons,
&usage,
&discounts,
args[4].as_i64(),
args[5].as_str(),
args[6].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 5 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.invoice
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./subscriptions.invoice-1.0.0-rust.fune, or fetch it from a terminal with fune pull subscriptions.invoice@1.0.0:rust.
The whole function, every language, is one file too: subscriptions.invoice-1.0.0.fune, 46,731 bytes, sha256 7282cffb4acedf2a62ba3f257a023e3d5357736d3babcede57565cd471e3ccc7. 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.invoice
after — your function gets the result and the arguments, and returns the final result.
// fune: after subscriptions.invoice
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 finance.invoice.calculate in subscriptions.invoice
// fune: replace finance.invoice.line-total in subscriptions.invoice
// fune: replace money.allocate in subscriptions.invoice
// fune: replace money.amount in subscriptions.invoice
// fune: replace money.apply-rate in subscriptions.invoice
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.invoice --steps.
// fune: step subscriptions.invoice 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 | |
|---|---|---|---|
| plan, seats and usage with no coupons | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, , 5, GB, 2026-09-01 | → | currency GBP, lines ×3, subtotal £91.50, tax total £18.30, total £109.80, tax breakdown ×1 |
| a 20% forever coupon becomes one discount line, taxed like the lines it reduces | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 5, GB, 2026-09-01 | → | currency GBP, lines ×4, subtotal £73.20, tax total £14.64, total £87.84, tax breakdown ×1 |
| a 3-period coupon from period 2 still applies in period 4 | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 4, GB, 2026-09-01 | → | currency GBP, lines ×4, subtotal £82.35, tax total £16.47, total £98.82, tax breakdown ×1 |
| the same coupon has run out by period 5 | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 5, GB, 2026-09-01 | → | currency GBP, lines ×3, subtotal £91.50, tax total £18.30, total £109.80, tax breakdown ×1 |
| a once coupon applies in its start period | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 5, GB, 2026-09-01 | → | currency GBP, lines ×4, subtotal £86.50, tax total £17.30, total £103.80, tax breakdown ×1 |
| a once coupon does not apply in the period after | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 5, GB, 2026-09-01 | → | currency GBP, lines ×3, subtotal £91.50, tax total £18.30, total £109.80, tax breakdown ×1 |
| a forever coupon does not apply before its start period | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, usage ×1, discounts ×1, 5, GB, 2026-09-01 | → | currency GBP, lines ×3, subtotal £91.50, tax total £18.30, total £109.80, tax breakdown ×1 |
| an amount coupon larger than the invoice takes it to zero, not below | description Starter plan, unit price £10.00, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×1, 1, GB, 2026-09-01 | → | currency GBP, lines ×2, subtotal £0.00, tax total £0.00, total £0.00, tax breakdown ×1 |
| a coupon across standard and zero-rated lines splits by net and is taxed per category | description Team plan, unit price £100.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, , discounts ×1, 3, GB, 2026-09-01 | → | currency GBP, lines ×4, subtotal £112.50, tax total £18.00, total £130.50, tax breakdown ×2 |
| coupons apply in order: half price then 10.00 off is 35.00, not 40.00 | description Business plan, unit price £90.00, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×2, 2, GB, 2026-09-01 | → | currency GBP, lines ×3, subtotal £35.00, tax total £7.00, total £42.00, tax breakdown ×1 |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a percentage coupon rounds half-up to the penny: 15% of 9.99 is 1.50 | description Solo plan, unit price £9.99, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×1, 1, GB, 2026-09-01 | → | currency GBP, lines ×2, subtotal £8.49, tax total £1.70, total £10.19, tax breakdown ×1 |
| a proration credit goes in as an add-on and the coupon applies to what is left | description Pro plan, unit price £20.00, quantity 1, discount basis points 0%, tax category STANDARD, add ons ×1, , discounts ×1, 7, GB, 2026-09-01 | → | currency GBP, lines ×3, subtotal £13.50, tax total £2.70, total £16.20, tax breakdown ×1 |
| a usage line keeps its own per-line discount | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, , usage ×1, , 1, GB, 2026-09-01 | → | currency GBP, lines ×2, subtotal £94.00, tax total £18.80, total £112.80, tax breakdown ×1 |
| a coupon with both a percentage and an amount is an error | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×1, 1, GB, 2026-09-01 | → | error: a discount needs exactly one of basisPoints and amountOff |
| a coupon with neither is an error | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×1, 1, GB, 2026-09-01 | → | error: a discount needs exactly one of basisPoints and amountOff |
| a repeating coupon without a duration is an error | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×1, 1, GB, 2026-09-01 | → | error: a repeating discount needs durationPeriods of 1 or more |
| a percentage above 100% is an error | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×1, 1, GB, 2026-09-01 | → | error: discount basis points must be between 0 and 10000 |
| an amount coupon in another currency is an error | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, , , discounts ×1, 1, GB, 2026-09-01 | → | error: currency mismatch |
| period zero is an error | description Pro plan, unit price £49.00, quantity 1, discount basis points 0%, tax category STANDARD, , , , 0, GB, 2026-09-01 | → | error: period number must be 1 or more |
More from the author
**How much.** The coupons that apply are taken in the order given, each from what is left after the ones before it. A percentage coupon takes that percentage of the remaining net, rounded half-up (money.apply-rate); an amount coupon takes its amount, but never more than what is left, so an invoice is never driven below zero by coupons (the unused part of an amount coupon is lost, as in Stripe). Order matters: 50% off then 10.00 off 90.00 is 35.00, the other way round it is 40.00.
**How it is taxed.** Coupons reduce the taxable amount, as a discount given at the time of supply does for UK VAT. Each coupon becomes a negative line, one per VAT category on the invoice, with its amount split between categories in proportion to their net (money.allocate, so the split adds up exactly), and each line is taxed at its category's rate like any other line. On an invoice where everything is standard-rated, which is most SaaS, that is one discount line per coupon.
Proration credits and charges (subscriptions.proration) go in as add-on lines with negative or positive prices. They are discounted along with everything else; Stripe instead marks proration lines as not discountable, so leave coupons off an invoice that should match Stripe exactly, or apply them before prorating.
Errors: a period number below 1; a coupon with both or neither of basisPoints and amountOff, a percentage outside 0 to 10000, a negative or foreign-currency amount, a start period below 1, a repeating coupon without durationPeriods or another kind with one; and everything finance.invoice.calculate refuses (mixed currencies, unknown tax categories). The invoice's currency is the plan's.
Files
| Path | Bytes |
|---|---|
| README.md | 2,662 |
| impl/python.py | 4,546 |
| impl/rust.rs | 6,163 |
| impl/typescript.ts | 4,242 |
| vectors.json | 21,835 |