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.
def payment_due_date(invoice_date: str, terms: PaymentTerms) -> str
| 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 = Literal["net", "end-of-month", "days-after-month-end", "day-of-next-month"]
@dataclass(frozen=True)
class PaymentTerms:
"""Credit terms, including any early settlement discount, so "2/10 net 30" is one value."""
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
n: int
#: early settlement discount, 200 = 2%; 0 when the terms offer none
discount_basis_points: int
#: days after the invoice date the discount stays open; 0 when none
discount_days: int
Your code names it in one line, in the file that uses it
from fune.finance.payment_terms_due_date import payment_due_date # 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.
from .dates_add_days import add_days ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months ← from dates.add-months ^1.0.0 · built alongside by fune
from .dates_month_boundaries import month_boundaries ← from dates.month-boundaries ^1.0.0 · built alongside by fune
from .finance_payment_terms_due_date_types import PaymentTerms
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _due_date_only(invoice_date: str, terms: PaymentTerms) -> str:
n = terms.n
if terms.kind in ("net", "end-of-month", "days-after-month-end"):
if not _is_int(n) or n < 0:
raise ValueError("payment terms n must be 0 or more, received %s" % (n,))
if terms.kind == "net":
return add_days(invoice_date, n)
if terms.kind == "end-of-month":
# 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.
return month_boundaries(add_months(invoice_date, n)).end
return add_days(month_boundaries(invoice_date).end, n)
if terms.kind == "day-of-next-month":
if not _is_int(n) or n < 1 or n > 31:
raise ValueError("day-of-next-month needs a day from 1 to 31, received %s" % (n,))
nxt = month_boundaries(add_months(month_boundaries(invoice_date).start, 1))
return add_days(nxt.start, min(n, nxt.days) - 1)
raise ValueError('unknown payment terms kind "%s"' % (terms.kind,))
def payment_due_date(invoice_date: str, terms: PaymentTerms) -> str:
"""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.
"""
due = _due_date_only(invoice_date, terms)
bp = terms.discount_basis_points
if not _is_int(bp) or bp < 0 or bp > 10000:
raise ValueError("discount must be 0 to 10000 basis points, received %s" % (bp,))
days = terms.discount_days
if not _is_int(days) or days < 0:
raise ValueError("discount days must be 0 or more, received %s" % (days,))
if bp > 0 and add_days(invoice_date, days) > due:
raise ValueError("the discount period ends after the due date %s" % (due,))
return dueInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 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 finance.payment-terms-due-date
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./finance.payment-terms-due-date-1.0.0-python.fune, or fetch it from a terminal with fune pull finance.payment-terms-due-date@1.0.0:python.
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 |