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
paymentDueDate(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 FebruarypaymentDueDate(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)paymentDueDate(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.
export function paymentDueDate(invoiceDate: string, terms: PaymentTerms): string
| invoiceDate | 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
export type PaymentTermsKind = "net" | "end-of-month" | "days-after-month-end" | "day-of-next-month";
/** Credit terms, including any early settlement discount, so "2/10 net 30" is one value. */
export interface PaymentTerms {
readonly kind: PaymentTermsKind;
/** 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 */
readonly n: number;
/** early settlement discount, 200 = 2%; 0 when the terms offer none */
readonly discountBasisPoints: number;
/** days after the invoice date the discount stays open; 0 when none */
readonly discountDays: number;
}
Your code names it in one line, in the file that uses it
import { paymentDueDate } from "#fune/finance.payment-terms-due-date@^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 { addMonths } from "./dates_add_months.ts"; ← from dates.add-months ^1.0.0 · built alongside by fune
import { monthBoundaries } from "./dates_month_boundaries.ts"; ← from dates.month-boundaries ^1.0.0 · built alongside by fune
import { type PaymentTerms } from "./finance_payment_terms_due_date_types.ts";
function dueDateOnly(invoiceDate: string, terms: PaymentTerms): string {
const n = terms.n;
switch (terms.kind) {
case "net":
if (!Number.isInteger(n) || n < 0) throw new RangeError(`payment terms n must be 0 or more, received ${n}`);
return addDays(invoiceDate, n);
case "end-of-month":
if (!Number.isInteger(n) || n < 0) throw new RangeError(`payment terms n must be 0 or more, received ${n}`);
// Move to the target month first, then take its end: the day of the
// month is irrelevant, so addMonths' clamping cannot shift the answer.
return monthBoundaries(addMonths(invoiceDate, n)).end;
case "days-after-month-end":
if (!Number.isInteger(n) || n < 0) throw new RangeError(`payment terms n must be 0 or more, received ${n}`);
return addDays(monthBoundaries(invoiceDate).end, n);
case "day-of-next-month": {
if (!Number.isInteger(n) || n < 1 || n > 31) {
throw new RangeError(`day-of-next-month needs a day from 1 to 31, received ${n}`);
}
const next = monthBoundaries(addMonths(monthBoundaries(invoiceDate).start, 1));
return addDays(next.start, Math.min(n, next.days) - 1);
}
default:
throw new RangeError(`unknown payment terms kind "${terms.kind}"`);
}
}
/**
* 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.
*/
export function paymentDueDate(invoiceDate: string, terms: PaymentTerms): string {
const due = dueDateOnly(invoiceDate, terms);
const bp = terms.discountBasisPoints;
if (!Number.isInteger(bp) || bp < 0 || bp > 10000) {
throw new RangeError(`discount must be 0 to 10000 basis points, received ${bp}`);
}
const days = terms.discountDays;
if (!Number.isInteger(days) || days < 0) {
throw new RangeError(`discount days must be 0 or more, received ${days}`);
}
if (bp > 0 && addDays(invoiceDate, days) > due) {
throw new RangeError(`the discount period ends after the due date ${due}`);
}
return due;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 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.payment-terms-due-date
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./finance.payment-terms-due-date-1.0.0-typescript.fune, or fetch it from a terminal with fune pull finance.payment-terms-due-date@1.0.0:typescript.
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 |