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 <noreply@anthropic.com>
This commit is contained in:
parent
bf5c463e93
commit
a66e13d5db
1 changed files with 67 additions and 0 deletions
|
|
@ -11,8 +11,23 @@ pub enum LoanOfferError {
|
||||||
ZeroLoanDuration,
|
ZeroLoanDuration,
|
||||||
#[error("borrowed capital should be strictly positive: {0}")]
|
#[error("borrowed capital should be strictly positive: {0}")]
|
||||||
NonPositiveBorrowedCapital(Euros),
|
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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub struct LoanOffer {
|
pub struct LoanOffer {
|
||||||
borrowed_capital: Euros,
|
borrowed_capital: Euros,
|
||||||
|
|
@ -28,10 +43,20 @@ impl LoanOffer {
|
||||||
) -> Result<Self, LoanOfferError> {
|
) -> Result<Self, LoanOfferError> {
|
||||||
if annual_nominal_rate < Decimal::ZERO {
|
if annual_nominal_rate < Decimal::ZERO {
|
||||||
Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate))
|
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 {
|
} else if loan_duration_in_months == 0 {
|
||||||
Err(LoanOfferError::ZeroLoanDuration)
|
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) {
|
} else if borrowed_capital <= Euros::from_cents_as_i64(0) {
|
||||||
Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital))
|
Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital))
|
||||||
|
} else if borrowed_capital > MAX_BORROWED_CAPITAL {
|
||||||
|
Err(LoanOfferError::MaxBorrowedCapitalExceeded(borrowed_capital))
|
||||||
} else {
|
} else {
|
||||||
Ok(LoanOffer {
|
Ok(LoanOffer {
|
||||||
borrowed_capital,
|
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]
|
#[test]
|
||||||
fn creating_loan_offer_with_null_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
fn creating_loan_offer_with_null_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue