Functional Weave
Code in Rust

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.

pub fn payment_due_date(invoice_date: &str, terms: &PaymentTerms) -> String
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 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(…)
impl/rust.rs · 68 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.

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
Download for Rust finance.payment-terms-due-date-1.0.0-rust.fune · 13,005 bytes sha256 5771136278aaa2f665db18f0bb18af232a8d698dd0c41aaf9ceae7622408c538

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.

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