Functional Weave
Code in Python

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 February
  • payment_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_datedatethe date the terms count from, usually the invoice date
termsPaymentTermsthe credit terms agreed with the customer
returnsdatethe 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
impl/python.py · 47 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.

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 due

Install

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
Download for Python finance.payment-terms-due-date-1.0.0-python.fune · 12,551 bytes sha256 5ab043fc0cf3720d3974bb73d9b39f660cea3c07f6287bd11a646d22ac4aad36

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,714
impl/python.py2,290
impl/rust.rs2,721
impl/typescript.ts2,454
vectors.json5,070