use super::funejson::Value; use super::dates_add_days::add_days; use super::dates_add_months::add_months; use super::dates_month_boundaries::month_boundaries; 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]))) }