Functional Weave
Code in Rust

finance.payment-terms-due-date@1.0.0

impl/rust.rs

2,721 bytes · the Rust implementation · view 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])))
}