Functional Weave
Code in TypeScript

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

  • renewalInvoice(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
  • renewalInvoice(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
  • renewalInvoice(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.

export function renewalInvoice(plan: InvoiceLine, addOns: readonly InvoiceLine[], usage: readonly InvoiceLine[], discounts: readonly Discount[], periodNumber: number, jurisdiction: string, invoiceDate: string): Invoice
planInvoiceLinethe plan's line for this period
addOnsInvoiceLine[]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
periodNumberintwhich billing period of the subscription this invoice is for, 1-based
jurisdictionstringVAT jurisdiction, as finance.tax.vat-rate takes it: GB
invoiceDatedatethe tax point, which decides the VAT rates
returnsInvoice

The types it declares, generated into your project

export type DiscountDuration = "once" | "repeating" | "forever";

/** A coupon on the subscription, shaped like a Stripe coupon. */
export interface Discount {
  /** printed on the discount line */
  readonly description: string;
  /** percent off; 2000 is 20% off. Exactly one of basisPoints and amountOff */
  readonly basisPoints: number | null;
  /** a fixed amount off each invoice it applies to */
  readonly amountOff: Money | null;
  readonly duration: DiscountDuration;
  /** repeating only: how many billing periods it lasts */
  readonly durationPeriods: number | null;
  /** the first billing period it applies to, 1-based */
  readonly startPeriod: number;
}

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

import { renewalInvoice } from "#fune/subscriptions.invoice@^1";
impl/typescript.ts · 92 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.

import { type Invoice, type InvoiceLine, calculateInvoice } from "./finance_invoice_calculate.ts";  ← from finance.invoice.calculate ^1.0.0 · built alongside by fune
import { lineTotal } from "./finance_invoice_line_total.ts";  ← from finance.invoice.line-total ^1.0.0 · built alongside by fune
import { allocate } from "./money_allocate.ts";  ← from money.allocate ^1.0.0 · built alongside by fune
import { assertSameCurrency, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts";  ← from money.apply-rate ^1.0.0 · built alongside by fune
import { type Discount } from "./subscriptions_invoice_types.ts";

function checkDiscount(d: Discount, currency: string): void {
  if ((d.basisPoints === null) === (d.amountOff === null)) {
    throw new RangeError(`a discount needs exactly one of basisPoints and amountOff: "${d.description}"`);
  }
  if (d.basisPoints !== null && (!Number.isInteger(d.basisPoints) || d.basisPoints < 0 || d.basisPoints > 10000)) {
    throw new RangeError(`discount basis points must be between 0 and 10000, received ${d.basisPoints}`);
  }
  if (d.amountOff !== null) {
    assertSameCurrency(money(0, currency), d.amountOff);
    if (d.amountOff.minor < 0) throw new RangeError(`discount amount must not be negative, received ${d.amountOff.minor}`);
  }
  if (!Number.isInteger(d.startPeriod) || d.startPeriod < 1) {
    throw new RangeError(`discount start period must be 1 or more, received ${d.startPeriod}`);
  }
  if (d.duration === "repeating") {
    if (d.durationPeriods === null || !Number.isInteger(d.durationPeriods) || d.durationPeriods < 1) {
      throw new RangeError(`a repeating discount needs durationPeriods of 1 or more, received ${d.durationPeriods}`);
    }
  } else if (d.duration === "once" || d.duration === "forever") {
    if (d.durationPeriods !== null) throw new RangeError(`durationPeriods applies only to a repeating discount: "${d.description}"`);
  } else {
    throw new RangeError(`unknown discount duration "${d.duration}": expected once, repeating or forever`);
  }
}

function applies(d: Discount, period: number): boolean {
  if (d.duration === "once") return period === d.startPeriod;
  if (d.duration === "repeating") return period >= d.startPeriod && period < d.startPeriod + (d.durationPeriods as number);
  return period >= d.startPeriod;
}

/**
 * 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.
 */
export function renewalInvoice(
  plan: InvoiceLine,
  addOns: readonly InvoiceLine[],
  usage: readonly InvoiceLine[],
  discounts: readonly Discount[],
  periodNumber: number,
  jurisdiction: string,
  invoiceDate: string,
): Invoice {
  if (!Number.isInteger(periodNumber) || periodNumber < 1) {
    throw new RangeError(`period number must be 1 or more, received ${periodNumber}`);
  }
  const currency = plan.unitPrice.currency;
  const items = [plan, ...addOns, ...usage];

  const categories: string[] = [];
  const categoryNet: number[] = [];
  let running = 0;
  for (const item of items) {
    const net = lineTotal(item.unitPrice, item.quantity, item.discountBasisPoints);
    assertSameCurrency(money(0, currency), net);
    let k = categories.indexOf(item.taxCategory);
    if (k < 0) {
      k = categories.length;
      categories.push(item.taxCategory);
      categoryNet.push(0);
    }
    categoryNet[k] += net.minor;
    running += net.minor;
  }
  // Credit-only categories take no share of a discount.
  const weights = categoryNet.map((n) => Math.max(n, 0));

  const discountLines: InvoiceLine[] = [];
  for (const d of discounts) {
    checkDiscount(d, currency);
    if (!applies(d, periodNumber) || running <= 0) continue;
    const amount = d.basisPoints !== null ? applyRate(money(running, currency), d.basisPoints, "half-up").minor : Math.min((d.amountOff as { minor: number }).minor, running);
    if (amount === 0) continue;
    running -= amount;
    allocate(money(amount, currency), weights).forEach((share, k) => {
      if (share.minor !== 0) {
        discountLines.push({ description: d.description, unitPrice: money(-share.minor, currency), quantity: 1, discountBasisPoints: 0, taxCategory: categories[k] });
      }
    });
  }
  return calculateInvoice([...items, ...discountLines], jurisdiction, invoiceDate);
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 5 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript subscriptions.invoice-1.0.0-typescript.fune · 35,644 bytes sha256 170714d14f6d6f4177b777631a3f798ff667d790f76c4535d7478369c0ff8d8b

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

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