finance.payment-terms-due-date
Invoice due date from payment terms: net N days, end of month, N days after month end, day N of next month.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 26 tests, run in TypeScript, Python and Rust.
What it does
The date an invoice falls due under its credit terms. Terms are a small record rather than a string such as "30 EOM", because the same words mean different things in different ledgers; the four kinds below are the ones accounting packages offer, and each says exactly what it counts from.
| kind | n means | example | |---|---|---| | `net` | days after the invoice date | net 30: 15 Jan is due 14 Feb | | `end-of-month` | months after the invoice month; 0 is the invoice month's own end | "end of month following" is n = 1: 10 Jan is due 28 Feb | | `days-after-month-end` | days after the last day of the invoice month | "30 days EOM": 15 Jan is due 2 Mar (28 days in Feb 2026) | | `day-of-next-month` | the day of the following month, 1 to 31 | "20th of next month": 5 Dec is due 20 Jan |
For example
payment_due_date(2026-01-15, kind net, n 30, discount basis points 0%, discount days 0)→ 2026-02-14 net 30 counts calendar days: 15 January is due 14 Februarypayment_due_date(2026-01-31, kind net, n 30, discount basis points 0%, discount days 0)→ 2026-03-02 net 30 from 31 January is 2 March in 2026, not 28 February (adding a month gets this wrong)payment_due_date(2024-01-31, kind net, n 30, discount basis points 0%, discount days 0)→ 2024-03-01 net 30 from 31 January 2024 crosses 29 February
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 payment_due_date(invoice_date: &str, terms: &PaymentTerms) -> String
| invoice_date | date | the date the terms count from, usually the invoice date |
| terms | PaymentTerms | the credit terms agreed with the customer |
| returns | date | the last day payment is on time; not moved off weekends or holidays |
The types it declares, generated into your project
// PaymentTermsKind is a string in Rust, one of: "net", "end-of-month", "days-after-month-end", "day-of-next-month".
// Parameters take it as &str and results hold it as String.
/// Credit terms, including any early settlement discount, so "2/10 net 30" is one value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaymentTerms {
pub kind: String,
/// net: days after the invoice date; end-of-month: months after the invoice month (0 = its own end); days-after-month-end: days after the invoice month ends; day-of-next-month: day 1 to 31
pub n: i64,
/// early settlement discount, 200 = 2%; 0 when the terms offer none
pub discount_basis_points: i64,
/// days after the invoice date the discount stays open; 0 when none
pub discount_days: i64,
}
Your code names it in one line, in the file that uses it
fune!(finance.payment-terms-due-date@^1); // then call payment_due_date(…)
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::dates_add_days::add_days; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_add_months::add_months; ← from dates.add-months ^1.0.0 · built alongside by fune
use super::dates_month_boundaries::month_boundaries; ← from dates.month-boundaries ^1.0.0 · built alongside by fune
fn due_date_only(invoice_date: &str, terms: &PaymentTerms) -> String {
let n = terms.n;
match terms.kind.as_str() {
"net" | "end-of-month" | "days-after-month-end" => {
if n < 0 {
panic!("payment terms n must be 0 or more, received {}", n);
}
match terms.kind.as_str() {
"net" => add_days(invoice_date, n),
// Move to the target month first, then take its end: the day of
// the month is irrelevant, so add_months' clamping cannot shift
// the answer.
"end-of-month" => month_boundaries(&add_months(invoice_date, n)).end,
_ => add_days(&month_boundaries(invoice_date).end, n),
}
}
"day-of-next-month" => {
if !(1..=31).contains(&n) {
panic!("day-of-next-month needs a day from 1 to 31, received {}", n);
}
let next = month_boundaries(&add_months(&month_boundaries(invoice_date).start, 1));
add_days(&next.start, n.min(next.days) - 1)
}
other => panic!("unknown payment terms kind \"{}\"", other),
}
}
/// The date an invoice falls due under its payment terms.
///
/// Net N is N calendar days, never "one month": 31 January net 30 is 2 March
/// in 2026. The discount fields do not move the due date, but they are checked
/// here so that terms whose discount outlasts the due date never get further.
///
/// # Panics
/// Panics on a malformed date, an unknown kind, or out-of-range terms.
pub fn payment_due_date(invoice_date: &str, terms: &PaymentTerms) -> String {
let due = due_date_only(invoice_date, terms);
let bp = terms.discount_basis_points;
if !(0..=10000).contains(&bp) {
panic!("discount must be 0 to 10000 basis points, received {}", bp);
}
let days = terms.discount_days;
if days < 0 {
panic!("discount days must be 0 or more, received {}", days);
}
if bp > 0 && add_days(invoice_date, days) > due {
panic!("the discount period ends after the due date {}", due);
}
due
}
pub fn payment_terms_from_value(v: &Value) -> PaymentTerms {
PaymentTerms {
kind: v.get("kind").as_str().to_string(),
n: v.get("n").as_i64(),
discount_basis_points: v.get("discountBasisPoints").as_i64(),
discount_days: v.get("discountDays").as_i64(),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::str(&payment_due_date(args[0].as_str(), &payment_terms_from_value(&args[1])))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 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 finance.payment-terms-due-date
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./finance.payment-terms-due-date-1.0.0-rust.fune, or fetch it from a terminal with fune pull finance.payment-terms-due-date@1.0.0:rust.
The whole function, every language, is one file too: finance.payment-terms-due-date-1.0.0.fune, 17,935 bytes, sha256 0455561cbc8963d1c565857524d43b7d30a46e9260f81d847729f9155eedd3d9. 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.payment-terms-due-date
after — your function gets the result and the arguments, and returns the final result.
// fune: after finance.payment-terms-due-date
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.payment-terms-due-date
// fune: replace dates.add-months in finance.payment-terms-due-date
// fune: replace dates.month-boundaries in finance.payment-terms-due-date
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.payment-terms-due-date --steps.
// fune: step finance.payment-terms-due-date 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 | |
|---|---|---|---|
| net 30 counts calendar days: 15 January is due 14 February | 2026-01-15, kind net, n 30, discount basis points 0%, discount days 0 | → | 2026-02-14 |
| net 30 from 31 January is 2 March in 2026, not 28 February (adding a month gets this wrong) | 2026-01-31, kind net, n 30, discount basis points 0%, discount days 0 | → | 2026-03-02 |
| net 30 from 31 January 2024 crosses 29 February | 2024-01-31, kind net, n 30, discount basis points 0%, discount days 0 | → | 2024-03-01 |
| net 0 is due on the invoice date | 2026-03-10, kind net, n 0, discount basis points 0%, discount days 0 | → | 2026-03-10 |
| net 60 across a year end | 2026-11-15, kind net, n 60, discount basis points 0%, discount days 0 | → | 2027-01-14 |
| end of month, n 0: the invoice month's own last day | 2026-02-10, kind end-of-month, n 0, discount basis points 0%, discount days 0 | → | 2026-02-28 |
| end of month following, invoiced on the 31st | 2026-01-31, kind end-of-month, n 1, discount basis points 0%, discount days 0 | → | 2026-02-28 |
| end of month following in a leap year | 2024-01-15, kind end-of-month, n 1, discount basis points 0%, discount days 0 | → | 2024-02-29 |
| end of the second month following rolls the year | 2026-11-20, kind end-of-month, n 2, discount basis points 0%, discount days 0 | → | 2027-01-31 |
| 30 days after month end | 2026-01-15, kind days-after-month-end, n 30, discount basis points 0%, discount days 0 | → | 2026-03-02 |
Show the other 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 30 days after month end is the same for an invoice on the last day | 2026-01-31, kind days-after-month-end, n 30, discount basis points 0%, discount days 0 | → | 2026-03-02 |
| 0 days after month end is the month end | 2026-04-05, kind days-after-month-end, n 0, discount basis points 0%, discount days 0 | → | 2026-04-30 |
| 20th of next month across a year end | 2026-12-05, kind day-of-next-month, n 20, discount basis points 0%, discount days 0 | → | 2027-01-20 |
| 31st of next month is clamped to 28 February | 2026-01-10, kind day-of-next-month, n 31, discount basis points 0%, discount days 0 | → | 2026-02-28 |
| day of next month from the last day of a month | 2026-01-31, kind day-of-next-month, n 15, discount basis points 0%, discount days 0 | → | 2026-02-15 |
| 2/10 net 30: the discount does not move the due date | 2026-03-01, kind net, n 30, discount basis points 2%, discount days 10 | → | 2026-03-31 |
| a discount open until the due date itself is allowed | 2026-03-01, kind net, n 30, discount basis points 2%, discount days 30 | → | 2026-03-31 |
| negative days are refused | 2026-03-01, kind net, n -1, discount basis points 0%, discount days 0 | → | error: payment terms n must be 0 or more |
| negative months are refused | 2026-03-01, kind end-of-month, n -1, discount basis points 0%, discount days 0 | → | error: payment terms n must be 0 or more |
| day 0 of next month is refused | 2026-03-01, kind day-of-next-month, n 0, discount basis points 0%, discount days 0 | → | error: day-of-next-month needs a day from 1 to 31 |
| day 32 of next month is refused | 2026-03-01, kind day-of-next-month, n 32, discount basis points 0%, discount days 0 | → | error: day-of-next-month needs a day from 1 to 31 |
| a discount over 100 percent is refused | 2026-03-01, kind net, n 30, discount basis points 100.01%, discount days 10 | → | error: discount must be 0 to 10000 basis points |
| negative discount days are refused | 2026-03-01, kind net, n 30, discount basis points 2%, discount days -1 | → | error: discount days must be 0 or more |
| 2/40 net 30: a discount that outlasts the due date is refused | 2026-03-01, kind net, n 30, discount basis points 2%, discount days 40 | → | error: the discount period ends after the due date 2026-03-31 |
| an impossible invoice date is refused | 2026-02-30, kind net, n 30, discount basis points 0%, discount days 0 | → | error: not a real calendar date |
| an unknown kind is refused | 2026-03-01, kind weekly, n 1, discount basis points 0%, discount days 0 | → | error: unknown payment terms kind |
More from the author
Net 30 is thirty calendar days, not "one month": 31 January net 30 is 2 March in 2026 and 1 March in 2024, where adding a month would say 28 or 29 February. A day of the month that the next month lacks is clamped to its last day, so "31st of next month" in January is 28 February.
"2/10 net 30" is `{ kind: net, n: 30, discountBasisPoints: 200, discountDays: 10 }`. The discount fields do not change the due date; they are validated here, so terms whose discount window outlasts the due date are refused wherever they are used, and finance.early-payment-discount reads them.
The due date is not moved off a weekend or bank holiday. Whether it moves, and which way, is a term of the contract; use dates.add-business-days when it does. The date is the last day payment is on time: under the UK Late Payment Act, statutory interest starts the day after it (finance.late-payment-interest).
Files
| Path | Bytes |
|---|---|
| README.md | 1,714 |
| impl/python.py | 2,290 |
| impl/rust.rs | 2,721 |
| impl/typescript.ts | 2,454 |
| vectors.json | 5,070 |