Functional Weave
Code in Python

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 coupons
  • renewal_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 reduces
  • renewal_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
planInvoiceLinethe plan's line for this period
add_onsInvoiceLine[]seats, extras and one-off lines such as proration credits (negative prices)
usageInvoiceLine[]metered usage lines, e.g. priced by subscriptions.usage-tiered
discountsDiscount[]coupons on the subscription, applied in this order
period_numberintwhich billing period of the subscription this invoice is for, 1-based
jurisdictionstringVAT jurisdiction, as finance.tax.vat-rate takes it: GB
invoice_datedatethe tax point, which decides the VAT rates
returnsInvoice

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
impl/python.py · 103 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 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
Download for Python subscriptions.invoice-1.0.0-python.fune · 35,955 bytes sha256 315d517a3ce7bd3f4b1e85f91a58cb74d10dfadd39063d05ef61f968f1970b64

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,662
impl/python.py4,546
impl/rust.rs6,163
impl/typescript.ts4,242
vectors.json21,835