finance.early-payment-discount
Early settlement discount available on a payment date under "2/10 net 30" style terms, and the amount to pay.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 16 tests, run in TypeScript, Python and Rust.
What it does
Under "2/10 net 30" a customer may take 2% off if they pay within 10 days of the invoice date; otherwise the full amount is due in 30. This says, for a given payment date, whether the discount is still open, how much it is, and what to pay.
The window is inclusive: with 10 days from 1 March, a payment on 11 March still earns the discount and one on 12 March does not. It counts calendar days from the invoice date, the same way net 30 does in finance.payment-terms-due-date, which also validates the terms (a discount window longer than the credit period is refused). A payment made before the invoice date is early and earns the discount.
For example
earlyPaymentDiscount(£1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-05)→ available true, discount £20.00, amount to pay £980.00, discount deadline 2026-03-11, due date 2026-03-31 2/10 net 30 paid on day 4 earns 2%earlyPaymentDiscount(£1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-11)→ available true, discount £20.00, amount to pay £980.00, discount deadline 2026-03-11, due date 2026-03-31 paid on the tenth day after the invoice: the window is inclusiveearlyPaymentDiscount(£1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-12)→ available false, discount £0.00, amount to pay £1,000.00, discount deadline 2026-03-11, due date 2026-03-31 paid on the eleventh day: the discount has gone
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 earlyPaymentDiscount(invoiceTotal: Money, invoiceDate: string, terms: PaymentTerms, paymentDate: string): EarlyPaymentDiscount
| invoiceTotal | Money | the amount the discount is taken from, usually the invoice total |
| invoiceDate | date | the date the terms count from |
| terms | PaymentTerms | the credit terms, with their discount fields |
| paymentDate | date | the date the payment is made |
| returns | EarlyPaymentDiscount |
The type it declares, generated into your project
/** Whether the discount can be taken on the payment date, and what that leaves to pay. */
export interface EarlyPaymentDiscount {
readonly available: boolean;
/** zero when the discount is not available */
readonly discount: Money;
/** invoice total less the discount */
readonly amountToPay: Money;
/** last day the discount can be taken; null when the terms offer none */
readonly discountDeadline: string | null;
readonly dueDate: string;
}
Your code names it in one line, in the file that uses it
import { earlyPaymentDiscount } from "#fune/finance.early-payment-discount@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { addDays } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { paymentDueDate } from "./finance_payment_terms_due_date.ts"; ← from finance.payment-terms-due-date ^1.0.0 · built alongside by fune
import { type PaymentTerms } from "./finance_payment_terms_due_date_types.ts";
import { subtractMoney } from "./money_add.ts"; ← from money.add ^1.0.0 · built alongside by fune
import { type Money, 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 EarlyPaymentDiscount } from "./finance_early_payment_discount_types.ts";
/**
* The settlement discount open on `paymentDate`, and what is left to pay.
*
* The window is inclusive and counted in calendar days from the invoice date,
* so under 2/10 the tenth day after the invoice still earns the discount.
*/
export function earlyPaymentDiscount(
invoiceTotal: Money,
invoiceDate: string,
terms: PaymentTerms,
paymentDate: string,
): EarlyPaymentDiscount {
if (invoiceTotal.minor < 0) {
throw new RangeError("invoice total must not be negative");
}
// Validates the terms, including a discount window that outlasts the due date.
const dueDate = paymentDueDate(invoiceDate, terms);
// Validates the payment date; ISO dates then compare as strings.
addDays(paymentDate, 0);
const offered = terms.discountBasisPoints > 0;
const discountDeadline = offered ? addDays(invoiceDate, terms.discountDays) : null;
const available = discountDeadline !== null && paymentDate <= discountDeadline;
const discount = available
? applyRate(invoiceTotal, terms.discountBasisPoints, "half-up")
: money(0, invoiceTotal.currency);
return {
available,
discount,
amountToPay: subtractMoney(invoiceTotal, discount),
discountDeadline,
dueDate,
};
}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 finance.early-payment-discount
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./finance.early-payment-discount-1.0.0-typescript.fune, or fetch it from a terminal with fune pull finance.early-payment-discount@1.0.0:typescript.
The whole function, every language, is one file too: finance.early-payment-discount-1.0.0.fune, 16,977 bytes, sha256 f68a7c9391ad1a546ab9a34c51f6b02a3d0dfcee0c2784d17193b5b775b9c029. 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 finance.early-payment-discount
after — your function gets the result and the arguments, and returns the final result.
// fune: after finance.early-payment-discount
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.add-days in finance.early-payment-discount
// fune: replace finance.payment-terms-due-date in finance.early-payment-discount
// fune: replace money.add in finance.early-payment-discount
// fune: replace money.amount in finance.early-payment-discount
// fune: replace money.apply-rate in finance.early-payment-discount
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 finance.early-payment-discount --steps.
// fune: step finance.early-payment-discount 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 | |
|---|---|---|---|
| 2/10 net 30 paid on day 4 earns 2% | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-05 | → | available true, discount £20.00, amount to pay £980.00, discount deadline 2026-03-11, due date 2026-03-31 |
| paid on the tenth day after the invoice: the window is inclusive | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-11 | → | available true, discount £20.00, amount to pay £980.00, discount deadline 2026-03-11, due date 2026-03-31 |
| paid on the eleventh day: the discount has gone | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-12 | → | available false, discount £0.00, amount to pay £1,000.00, discount deadline 2026-03-11, due date 2026-03-31 |
| paid after the due date: no discount | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-04-15 | → | available false, discount £0.00, amount to pay £1,000.00, discount deadline 2026-03-11, due date 2026-03-31 |
| net 30 without a discount has no deadline | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 0%, discount days 0, 2026-03-02 | → | available false, discount £0.00, amount to pay £1,000.00, discount deadline —, due date 2026-03-31 |
| the discount is rounded half-up once: 2% of 1234.57 is 24.69 | £1,234.57, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-02 | → | available true, discount £24.69, amount to pay £1,209.88, discount deadline 2026-03-11, due date 2026-03-31 |
| an exact half penny rounds up: 2% of 0.25 | £0.25, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-02 | → | available true, discount £0.01, amount to pay £0.24, discount deadline 2026-03-11, due date 2026-03-31 |
| 1.5/10: 1.5% of 999.99 | £999.99, 2026-03-01, kind net, n 30, discount basis points 1.5%, discount days 10, 2026-03-10 | → | available true, discount £15.00, amount to pay £984.99, discount deadline 2026-03-11, due date 2026-03-31 |
| window crosses a month end in a leap year | £500.00, 2024-02-25, kind net, n 30, discount basis points 2%, discount days 10, 2024-03-06 | → | available true, discount £10.00, amount to pay £490.00, discount deadline 2024-03-06, due date 2024-03-26 |
| paying before the invoice date is early too | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-02-27 | → | available true, discount £20.00, amount to pay £980.00, discount deadline 2026-03-11, due date 2026-03-31 |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a zero total has a zero discount | £0.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-02 | → | available true, discount £0.00, amount to pay £0.00, discount deadline 2026-03-11, due date 2026-03-31 |
| zero-decimal currency | ¥100,000, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-02 | → | available true, discount ¥2,000, amount to pay ¥98,000, discount deadline 2026-03-11, due date 2026-03-31 |
| a same-day-only discount with 0 discount days | £1,000.00, 2026-03-01, kind end-of-month, n 1, discount basis points 3%, discount days 0, 2026-03-01 | → | available true, discount £30.00, amount to pay £970.00, discount deadline 2026-03-01, due date 2026-04-30 |
| a negative total is refused | -£1.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-03-02 | → | error: invoice total must not be negative |
| terms whose discount outlasts the due date are refused | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 40, 2026-03-02 | → | error: the discount period ends after the due date |
| an impossible payment date is refused | £1,000.00, 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10, 2026-02-30 | → | error: not a real calendar date |
More from the author
The discount is the rate applied to the total with money.apply-rate, rounded half-up to the minor unit once. `amountToPay` is the total less that discount, so the two always add back to the total.
What the discount is taken from is the caller's choice: pass the gross invoice total for a plain settlement discount. UK VAT on prompt payment discounts has been due on the amount actually paid since 1 April 2015 (HMRC Revenue and Customs Brief 49/14), which means a supplier issuing a credit note or invoicing both options; that VAT arithmetic is finance.credit-note.calculate's and finance.invoice.calculate's, not this function's.
A negative total is refused: credit notes do not carry settlement discounts.
Files
| Path | Bytes |
|---|---|
| README.md | 1,388 |
| impl/python.py | 1,646 |
| impl/rust.rs | 2,527 |
| impl/typescript.ts | 1,658 |
| vectors.json | 5,900 |