Reject loan offers that can never amortise
At high rate and long duration the rounded instalment converges to C*i from above and lands exactly on the first month's interest, leaving zero principal: at 99 % the frontier is 294 months for the maximum capital. The schedule would never terminate. Checking the first month suffices: once principal is positive the outstanding capital decreases, so interest decreases and principal grows. The check must stay after the bounds checks, since it calls monthly_repayment() and that function's expect() is justified by them. creating_loan_offer_with_limit_inputs_works asserted that the three maxima combined were valid, which this invariant makes false. The valid domain is no longer a box. It is replaced by the corner that actually maximises the instalment, duration 1, at 1 082 499 999,99 EUR against 82 500 000,00 EUR for the corner it tested before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8d00f6a936
commit
f1ba015cc3
1 changed files with 29 additions and 11 deletions
|
|
@ -18,6 +18,8 @@ pub enum LoanOfferError {
|
||||||
MaxAnnualNominalRateExceeded(Decimal),
|
MaxAnnualNominalRateExceeded(Decimal),
|
||||||
#[error("loan duration in months should be lower than {MAX_LOAN_DURATION_IN_MONTHS}: {0}")]
|
#[error("loan duration in months should be lower than {MAX_LOAN_DURATION_IN_MONTHS}: {0}")]
|
||||||
MaxLoanDurationExceeded(u64),
|
MaxLoanDurationExceeded(u64),
|
||||||
|
#[error("loan cannot amortise")]
|
||||||
|
LoanCannotAmortise,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1b€ is a pretty expensive house...
|
/// 1b€ is a pretty expensive house...
|
||||||
|
|
@ -59,11 +61,21 @@ impl LoanOffer {
|
||||||
} else if borrowed_capital > MAX_BORROWED_CAPITAL {
|
} else if borrowed_capital > MAX_BORROWED_CAPITAL {
|
||||||
Err(LoanOfferError::MaxBorrowedCapitalExceeded(borrowed_capital))
|
Err(LoanOfferError::MaxBorrowedCapitalExceeded(borrowed_capital))
|
||||||
} else {
|
} else {
|
||||||
Ok(LoanOffer {
|
let loan_offer = LoanOffer {
|
||||||
borrowed_capital,
|
borrowed_capital,
|
||||||
annual_nominal_rate,
|
annual_nominal_rate,
|
||||||
loan_duration_in_months,
|
loan_duration_in_months,
|
||||||
})
|
};
|
||||||
|
// Check that the first month is not "only interests", otherwise the loan cannot be reimbursed
|
||||||
|
// /!\ Must be kept after all the previous verifications otherwise monthly_repayment() can panic
|
||||||
|
if loan_offer.monthly_repayment().as_decimal()
|
||||||
|
> (borrowed_capital.as_decimal() * loan_offer.proportional_monthly_interest_rate())
|
||||||
|
.round_dp(2)
|
||||||
|
{
|
||||||
|
Ok(loan_offer)
|
||||||
|
} else {
|
||||||
|
Err(LoanOfferError::LoanCannotAmortise)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,7 +110,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creating_loan_offer_with_negative_interest_rate_returns_negative_interest_rate_error() {
|
fn creating_loan_offer_with_negative_interest_rate_returns_negative_interest_rate_error() {
|
||||||
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(-45, 2), 5);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(-45, 2), 60);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ret,
|
ret,
|
||||||
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
|
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
|
||||||
|
|
@ -177,6 +189,16 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_loan_offer_that_cannot_amortise_returns_loan_cannot_amortise_error() {
|
||||||
|
let ret = LoanOffer::new(
|
||||||
|
Euros::from_cents_as_i64(439_000_00),
|
||||||
|
Decimal::new(99, 2),
|
||||||
|
480,
|
||||||
|
);
|
||||||
|
assert_eq!(ret, Err(LoanOfferError::LoanCannotAmortise));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creating_loan_offer_with_correct_inputs_works() {
|
fn creating_loan_offer_with_correct_inputs_works() {
|
||||||
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::new(45, 2), 15);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::new(45, 2), 15);
|
||||||
|
|
@ -191,12 +213,8 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creating_loan_offer_with_limit_inputs_works() {
|
fn creating_loan_offer_with_max_repayment_works() {
|
||||||
let ret = LoanOffer::new(
|
let ret = LoanOffer::new(MAX_BORROWED_CAPITAL, MAX_ANNUAL_NOMINAL_RATE, 1);
|
||||||
MAX_BORROWED_CAPITAL,
|
|
||||||
MAX_ANNUAL_NOMINAL_RATE,
|
|
||||||
MAX_LOAN_DURATION_IN_MONTHS,
|
|
||||||
);
|
|
||||||
assert!(ret.is_ok());
|
assert!(ret.is_ok());
|
||||||
// Should not panic - checks that expect assertion is valid
|
// Should not panic - checks that expect assertion is valid
|
||||||
ret.unwrap().monthly_repayment();
|
ret.unwrap().monthly_repayment();
|
||||||
|
|
@ -219,11 +237,11 @@ mod tests {
|
||||||
fn conversion_to_annual_nominal_rate_in_percent_works() {
|
fn conversion_to_annual_nominal_rate_in_percent_works() {
|
||||||
let offer = LoanOffer::new(
|
let offer = LoanOffer::new(
|
||||||
Euros::from_cents_as_i64(150_000_00),
|
Euros::from_cents_as_i64(150_000_00),
|
||||||
Decimal::new(4, 4),
|
Decimal::new(4, 2),
|
||||||
300,
|
300,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(4, 2));
|
assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(400, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue