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.
def renewal_invoice(plan: InvoiceLine, add_ons: Sequence[InvoiceLine], usage: Sequence[InvoiceLine], discounts: Sequence[Discount], period_number: int, 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 = Literal["once", "repeating", "forever"]
@dataclass(frozen=True)
class Discount:
"""A coupon on the subscription, shaped like a Stripe coupon."""
#: printed on the discount line
description: str
#: percent off; 2000 is 20% off. Exactly one of basisPoints and amountOff
basis_points: Optional[int]
#: a fixed amount off each invoice it applies to
amount_off: Optional[Money]
duration: DiscountDuration
#: repeating only: how many billing periods it lasts
duration_periods: Optional[int]
#: the first billing period it applies to, 1-based
start_period: int
Your code names it in one line, in the file that uses it
from fune.subscriptions.invoice import renewal_invoice # subscriptions.invoice@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Sequence
from .finance_invoice_calculate import Invoice, InvoiceLine, calculate_invoice ← from finance.invoice.calculate ^1.0.0 · built alongside by fune
from .finance_invoice_line_total import line_total ← from finance.invoice.line-total ^1.0.0 · built alongside by fune
from .money_allocate import allocate ← from money.allocate ^1.0.0 · built alongside by fune
from .money_amount import assert_same_currency, money ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate ← from money.apply-rate ^1.0.0 · built alongside by fune
from .subscriptions_invoice_types import Discount
def _is_int(value: object) -> bool:
return not isinstance(value, bool) and isinstance(value, int)
def _check_discount(d: Discount, currency: str) -> None:
if (d.basis_points is None) == (d.amount_off is None):
raise ValueError('a discount needs exactly one of basisPoints and amountOff: "%s"' % (d.description,))
if d.basis_points is not None and (not _is_int(d.basis_points) or d.basis_points < 0 or d.basis_points > 10000):
raise ValueError("discount basis points must be between 0 and 10000, received %r" % (d.basis_points,))
if d.amount_off is not None:
assert_same_currency(money(0, currency), d.amount_off)
if d.amount_off.minor < 0:
raise ValueError("discount amount must not be negative, received %d" % (d.amount_off.minor,))
if not _is_int(d.start_period) or d.start_period < 1:
raise ValueError("discount start period must be 1 or more, received %r" % (d.start_period,))
if d.duration == "repeating":
if d.duration_periods is None or not _is_int(d.duration_periods) or d.duration_periods < 1:
raise ValueError(
"a repeating discount needs durationPeriods of 1 or more, received %r" % (d.duration_periods,)
)
elif d.duration in ("once", "forever"):
if d.duration_periods is not None:
raise ValueError('durationPeriods applies only to a repeating discount: "%s"' % (d.description,))
else:
raise ValueError('unknown discount duration "%s": expected once, repeating or forever' % (d.duration,))
def _applies(d: Discount, period: int) -> bool:
if d.duration == "once":
return period == d.start_period
if d.duration == "repeating":
return d.start_period <= period < d.start_period + d.duration_periods
return period >= d.start_period
def renewal_invoice(
plan: InvoiceLine,
add_ons: Sequence[InvoiceLine],
usage: Sequence[InvoiceLine],
discounts: Sequence[Discount],
period_number: int,
jurisdiction: str,
invoice_date: str,
) -> Invoice:
"""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.
"""
if not _is_int(period_number) or period_number < 1:
raise ValueError("period number must be 1 or more, received %r" % (period_number,))
currency = plan.unit_price.currency
items = [plan, *add_ons, *usage]
categories: List[str] = []
category_net: List[int] = []
running = 0
for item in items:
net = line_total(item.unit_price, item.quantity, item.discount_basis_points)
assert_same_currency(money(0, currency), net)
if item.tax_category not in categories:
categories.append(item.tax_category)
category_net.append(0)
k = categories.index(item.tax_category)
category_net[k] += net.minor
running += net.minor
# Credit-only categories take no share of a discount.
weights = [max(n, 0) for n in category_net]
discount_lines: List[InvoiceLine] = []
for d in discounts:
_check_discount(d, currency)
if not _applies(d, period_number) or running <= 0:
continue
if d.basis_points is not None:
amount = apply_rate(money(running, currency), d.basis_points, "half-up").minor
else:
amount = min(d.amount_off.minor, running)
if amount == 0:
continue
running -= amount
for k, share in enumerate(allocate(money(amount, currency), weights)):
if share.minor != 0:
discount_lines.append(
InvoiceLine(
description=d.description,
unit_price=money(-share.minor, currency),
quantity=1,
discount_basis_points=0,
tax_category=categories[k],
)
)
return calculate_invoice(items + discount_lines, jurisdiction, invoice_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 5 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.invoice
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./subscriptions.invoice-1.0.0-python.fune, or fetch it from a terminal with fune pull subscriptions.invoice@1.0.0:python.
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 |