From a66e13d5db2852d166ce866a517ed16d8f79a666 Mon Sep 17 00:00:00 2001 From: Marcel Enguehard Date: Sun, 23 Aug 2026 15:37:10 +0200 Subject: [PATCH] Bound LoanOffer inputs so the arithmetic stays in range An offer with no upper bound on capital, rate or duration can be constructed and then overflow downstream. Capping all three at construction turns those overflows into a typed error at the boundary instead of a panic in the middle of a computation. The bounds are structural sanity limits chosen to leave several orders of magnitude of headroom, not domain figures. In particular MAX_ANNUAL_NOMINAL_RATE is not the taux d'usure, which applies to the TAEG, is published quarterly by the Banque de France, and belongs in a separate business rule with its own source and date. Co-Authored-By: Claude Opus 5 --- crates/immo-core/src/loan_offer.rs | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/immo-core/src/loan_offer.rs b/crates/immo-core/src/loan_offer.rs index 1e194db..3ac886a 100644 --- a/crates/immo-core/src/loan_offer.rs +++ b/crates/immo-core/src/loan_offer.rs @@ -11,8 +11,23 @@ pub enum LoanOfferError { ZeroLoanDuration, #[error("borrowed capital should be strictly positive: {0}")] NonPositiveBorrowedCapital(Euros), + #[error("borrowed capital should be lower than {MAX_BORROWED_CAPITAL}: {0}")] + MaxBorrowedCapitalExceeded(Euros), + #[error("annual nominal rate should be lower than {MAX_ANNUAL_NOMINAL_RATE}: {0}")] + MaxAnnualNominalRateExceeded(Decimal), + #[error("loan duration in months should be lower than {MAX_LOAN_DURATION_IN_MONTHS}: {0}")] + MaxLoanDurationExceeded(u64), } +/// 1b€ is a pretty expensive house... +pub const MAX_BORROWED_CAPITAL: Euros = Euros::from_cents_as_i64(999_999_999_99); + +/// 99% nominal rate will hopefully never happen +pub const MAX_ANNUAL_NOMINAL_RATE: Decimal = Decimal::from_parts(99, 0, 0, false, 2); + +/// Loan duration is theoretically constrained to max 25y in France, let's use 40 to be sure +pub const MAX_LOAN_DURATION_IN_MONTHS: u64 = 40 * 12; + #[derive(Debug, PartialEq, Eq)] pub struct LoanOffer { borrowed_capital: Euros, @@ -28,10 +43,20 @@ impl LoanOffer { ) -> Result { if annual_nominal_rate < Decimal::ZERO { Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate)) + } else if annual_nominal_rate > MAX_ANNUAL_NOMINAL_RATE { + Err(LoanOfferError::MaxAnnualNominalRateExceeded( + annual_nominal_rate, + )) } else if loan_duration_in_months == 0 { Err(LoanOfferError::ZeroLoanDuration) + } else if loan_duration_in_months > MAX_LOAN_DURATION_IN_MONTHS { + Err(LoanOfferError::MaxLoanDurationExceeded( + loan_duration_in_months, + )) } else if borrowed_capital <= Euros::from_cents_as_i64(0) { Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital)) + } else if borrowed_capital > MAX_BORROWED_CAPITAL { + Err(LoanOfferError::MaxBorrowedCapitalExceeded(borrowed_capital)) } else { Ok(LoanOffer { borrowed_capital, @@ -77,6 +102,48 @@ mod tests { ); } + #[test] + fn creating_loan_offer_with_borrowed_capital_higher_than_max_borrowed_capital_returns_max_borrowed_capital_exceeded_error() + { + let euros = MAX_BORROWED_CAPITAL + Euros::from_cents_as_i64(1); + let ret = LoanOffer::new(euros, Decimal::new(45, 2), 25); + assert_eq!(ret, Err(LoanOfferError::MaxBorrowedCapitalExceeded(euros))); + } + + #[test] + fn creating_loan_offer_with_annual_nominal_rate_higher_than_max_annual_nominal_rate_returns_max_annual_nominal_rate_exceeded_error() + { + let annual_nominal_rate = MAX_ANNUAL_NOMINAL_RATE + Decimal::new(1, 2); + let ret = LoanOffer::new( + Euros::from_cents_as_i64(100_000_00), + annual_nominal_rate, + 25, + ); + assert_eq!( + ret, + Err(LoanOfferError::MaxAnnualNominalRateExceeded( + annual_nominal_rate + )) + ); + } + + #[test] + fn creating_loan_offer_with_loan_duration_higher_than_max_loan_duration_returns_max_loan_duration_exceeded_error() + { + let loan_duration_in_months = MAX_LOAN_DURATION_IN_MONTHS + 1; + let ret = LoanOffer::new( + Euros::from_cents_as_i64(100_000_00), + Decimal::new(314, 4), + loan_duration_in_months, + ); + assert_eq!( + ret, + Err(LoanOfferError::MaxLoanDurationExceeded( + loan_duration_in_months + )) + ); + } + #[test] fn creating_loan_offer_with_null_borrowed_capital_returns_non_positive_borrowed_capital_error() {