Compare commits
No commits in common. "8d00f6a936e93593b5034dce2fbd451b59bb9a27" and "85b0836d6e0eeaa5cbf7fa72a1bac05eb98f64b1" have entirely different histories.
8d00f6a936
...
85b0836d6e
7 changed files with 33 additions and 6130 deletions
|
|
@ -68,7 +68,7 @@ docs.rs for that exact version.
|
|||
## Error handling and tests
|
||||
|
||||
- `thiserror` for typed errors in `immo-core`; `anyhow` only at the `immo-web` boundary.
|
||||
- No `unwrap()` or `panic!()` outside tests and `main`. `expect()` can only be used for cases where the Result will provably be `Ok()`
|
||||
- No `unwrap()`, `expect()`, or `panic!()` outside tests and `main`.
|
||||
- Every `core` function producing a number the user sees has a known-answer test, with the worked
|
||||
example stated in the test name or a comment above it.
|
||||
- Test the amortisation schedule against hand-checked values, never against the implementation's
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ edition.workspace = true
|
|||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rust_decimal = { version = "1.42.1", features = ["maths"] }
|
||||
rust_decimal = "1.42.1"
|
||||
thiserror = "2.0.19"
|
||||
|
||||
[lints.clippy]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
use std::{fmt, ops::Add};
|
||||
use std::fmt;
|
||||
|
||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Error, Clone)]
|
||||
pub enum EurosError {
|
||||
#[error("Can only create from decimal with scale = 2: {0}")]
|
||||
WrongDecimalScale(Decimal),
|
||||
#[error("Decimal cannot be represented as i64: {0}")]
|
||||
DecimalIsTooBig(Decimal),
|
||||
}
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)]
|
||||
pub struct Euros {
|
||||
|
|
@ -17,22 +8,10 @@ pub struct Euros {
|
|||
}
|
||||
|
||||
impl Euros {
|
||||
pub const fn from_cents_as_i64(value_as_cents: i64) -> Self {
|
||||
pub const fn from_cents(value_as_cents: i64) -> Self {
|
||||
Euros { value_as_cents }
|
||||
}
|
||||
|
||||
pub fn from_euros_as_decimal(value_as_euros: Decimal) -> Result<Self, EurosError> {
|
||||
// Euros should explicitely have scale 2 to validate cents precision
|
||||
if value_as_euros.scale() != 2 {
|
||||
Err(EurosError::WrongDecimalScale(value_as_euros))
|
||||
} else {
|
||||
(value_as_euros * Decimal::ONE_HUNDRED)
|
||||
.to_i64()
|
||||
.map(Euros::from_cents_as_i64)
|
||||
.ok_or(EurosError::DecimalIsTooBig(value_as_euros))
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_cents(self) -> i64 {
|
||||
self.value_as_cents
|
||||
}
|
||||
|
|
@ -48,29 +27,19 @@ impl fmt::Display for Euros {
|
|||
}
|
||||
}
|
||||
|
||||
impl Add for Euros {
|
||||
type Output = Self;
|
||||
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Euros {
|
||||
value_as_cents: self.value_as_cents + rhs.value_as_cents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_cents_to_euros_to_cents_stays_equals() {
|
||||
let euros = Euros::from_cents_as_i64(123_456_00);
|
||||
let euros = Euros::from_cents(123_456_00);
|
||||
assert_eq!(euros.as_cents(), 123_456_00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_positive_cents_to_decimal_has_scale_2_and_correct_mantissa() {
|
||||
let euros = Euros::from_cents_as_i64(123_456_78);
|
||||
let euros = Euros::from_cents(123_456_78);
|
||||
assert_eq!(
|
||||
euros.as_decimal().scale(),
|
||||
2,
|
||||
|
|
@ -85,7 +54,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn from_negative_cents_to_decimal_has_correct_scale_and_mantissa() {
|
||||
let euros = Euros::from_cents_as_i64(-123_456_78);
|
||||
let euros = Euros::from_cents(-123_456_78);
|
||||
assert_eq!(
|
||||
euros.as_decimal().scale(),
|
||||
2,
|
||||
|
|
@ -100,7 +69,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn from_max_i64_cents_to_decimal_has_correct_scale_and_mantissa() {
|
||||
let euros = Euros::from_cents_as_i64(i64::MAX);
|
||||
let euros = Euros::from_cents(i64::MAX);
|
||||
assert_eq!(
|
||||
euros.as_decimal().scale(),
|
||||
2,
|
||||
|
|
@ -115,59 +84,26 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn from_0_cents_to_decimal_stays_zero() {
|
||||
let euros = Euros::from_cents_as_i64(0);
|
||||
let euros = Euros::from_cents(0);
|
||||
assert_eq!(euros.as_decimal().scale(), 2);
|
||||
assert_eq!(euros.as_decimal(), Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_euros_prints_correctly() {
|
||||
let euros = Euros::from_cents_as_i64(123_45);
|
||||
let euros = Euros::from_cents(123_45);
|
||||
assert_eq!(format!("{}", euros), "123.45 €");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn euros_without_cents_prints_with_trailing_zeros() {
|
||||
let euros = Euros::from_cents_as_i64(123_00);
|
||||
let euros = Euros::from_cents(123_00);
|
||||
assert_eq!(format!("{}", euros), "123.00 €");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn euros_with_negative_amount_prints_with_minus() {
|
||||
let euros = Euros::from_cents_as_i64(-123_00);
|
||||
fn euros_with_negative_amount_print_with_minus() {
|
||||
let euros = Euros::from_cents(-123_00);
|
||||
assert_eq!(format!("{}", euros), "-123.00 €");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_euros_as_decimal_fails_if_scale_is_larger_than_2() {
|
||||
let dec = Decimal::new(3141, 3);
|
||||
let ret = Euros::from_euros_as_decimal(dec);
|
||||
assert!(ret.is_err());
|
||||
assert_eq!(ret.unwrap_err(), EurosError::WrongDecimalScale(dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_euros_as_decimal_fails_if_scale_is_lower_than_2() {
|
||||
let dec = Decimal::new(3141, 1);
|
||||
let ret = Euros::from_euros_as_decimal(dec);
|
||||
assert!(ret.is_err());
|
||||
assert_eq!(ret.unwrap_err(), EurosError::WrongDecimalScale(dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_euros_as_decimal_yields_correct_result_if_scale_is_2() {
|
||||
let dec = Decimal::new(3141, 2);
|
||||
let ret = Euros::from_euros_as_decimal(dec);
|
||||
assert!(ret.is_ok());
|
||||
assert_eq!(ret.unwrap().as_cents(), 3141);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_euros_as_decimal_yields_error_with_decimal_too_big() {
|
||||
let mut dec = Decimal::from(u64::MAX);
|
||||
dec.rescale(2);
|
||||
let ret = Euros::from_euros_as_decimal(dec);
|
||||
assert!(ret.is_err());
|
||||
assert_eq!(ret.unwrap_err(), EurosError::DecimalIsTooBig(dec));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
pub mod loan_offer_fixture;
|
||||
|
|
@ -1,5 +1,2 @@
|
|||
pub mod euros;
|
||||
pub mod loan_offer;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod fixtures;
|
||||
|
|
|
|||
|
|
@ -1,104 +1,54 @@
|
|||
use rust_decimal::Decimal;
|
||||
use rust_decimal::MathematicalOps;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::euros::Euros;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Error, Clone)]
|
||||
pub enum LoanOfferError {
|
||||
#[error("loan cannot have a negative interest rate: {0}")]
|
||||
#[error("loan cannot have a negative interest rate: {0}%")]
|
||||
NegativeInterestRate(Decimal),
|
||||
#[error("loan duration cannot be zero")]
|
||||
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,
|
||||
annual_nominal_rate: Decimal,
|
||||
annual_rate_in_percent: Decimal,
|
||||
loan_duration_in_months: u64,
|
||||
}
|
||||
|
||||
impl LoanOffer {
|
||||
pub fn new(
|
||||
borrowed_capital: Euros,
|
||||
annual_nominal_rate: Decimal,
|
||||
annual_rate_in_percent: Decimal,
|
||||
loan_duration_in_months: u64,
|
||||
) -> Result<Self, LoanOfferError> {
|
||||
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,
|
||||
))
|
||||
if annual_rate_in_percent < Decimal::ZERO {
|
||||
Err(LoanOfferError::NegativeInterestRate(annual_rate_in_percent))
|
||||
} 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) {
|
||||
} else if borrowed_capital <= Euros::from_cents(0) {
|
||||
Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital))
|
||||
} else if borrowed_capital > MAX_BORROWED_CAPITAL {
|
||||
Err(LoanOfferError::MaxBorrowedCapitalExceeded(borrowed_capital))
|
||||
} else {
|
||||
Ok(LoanOffer {
|
||||
borrowed_capital,
|
||||
annual_nominal_rate,
|
||||
annual_rate_in_percent,
|
||||
loan_duration_in_months,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn annual_nominal_rate_in_percent(&self) -> Decimal {
|
||||
self.annual_nominal_rate * Decimal::ONE_HUNDRED
|
||||
}
|
||||
|
||||
pub fn proportional_monthly_interest_rate(&self) -> Decimal {
|
||||
self.annual_nominal_rate / Decimal::from(12)
|
||||
}
|
||||
|
||||
pub fn monthly_repayment(&self) -> Euros {
|
||||
let monthly_rate = self.proportional_monthly_interest_rate();
|
||||
let mut repayment_in_euros = if monthly_rate == Decimal::ZERO {
|
||||
(self.borrowed_capital.as_decimal() / Decimal::from(self.loan_duration_in_months))
|
||||
.round_dp(2)
|
||||
} else {
|
||||
let repayment_numerator = self.borrowed_capital.as_decimal() * monthly_rate;
|
||||
let repayment_denominator = Decimal::ONE
|
||||
- (Decimal::ONE / (Decimal::ONE + monthly_rate).powu(self.loan_duration_in_months));
|
||||
(repayment_numerator / repayment_denominator).round_dp(2)
|
||||
};
|
||||
repayment_in_euros.rescale(2);
|
||||
Euros::from_euros_as_decimal(repayment_in_euros).expect("Borrowed capital and annual rate are constrained on construction, repayment_in_euros cannot exceed 10b, and scale=2 is guaranteed through rescale")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fixtures::loan_offer_fixture::*;
|
||||
|
||||
#[test]
|
||||
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(123), Decimal::new(-45, 2), 5);
|
||||
assert_eq!(
|
||||
ret,
|
||||
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
|
||||
|
|
@ -107,60 +57,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn creating_loan_offer_with_null_loan_duration_returns_zero_loan_duration_error() {
|
||||
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(45, 2), 0);
|
||||
let ret = LoanOffer::new(Euros::from_cents(123), Decimal::new(45, 2), 0);
|
||||
assert_eq!(ret, Err(LoanOfferError::ZeroLoanDuration));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_loan_offer_with_negative_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
||||
{
|
||||
let ret = LoanOffer::new(Euros::from_cents_as_i64(-1), Decimal::new(45, 2), 15);
|
||||
let ret = LoanOffer::new(Euros::from_cents(-1), Decimal::new(45, 2), 15);
|
||||
assert_eq!(
|
||||
ret,
|
||||
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
||||
Euros::from_cents_as_i64(-1)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
Euros::from_cents(-1)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
|
@ -168,87 +76,38 @@ mod tests {
|
|||
#[test]
|
||||
fn creating_loan_offer_with_null_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
||||
{
|
||||
let ret = LoanOffer::new(Euros::from_cents_as_i64(0), Decimal::new(45, 2), 15);
|
||||
let ret = LoanOffer::new(Euros::from_cents(0), Decimal::new(45, 2), 15);
|
||||
assert_eq!(
|
||||
ret,
|
||||
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
||||
Euros::from_cents_as_i64(0)
|
||||
Euros::from_cents(0)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(10), Decimal::new(45, 2), 15);
|
||||
assert_eq!(
|
||||
ret,
|
||||
Ok(LoanOffer {
|
||||
borrowed_capital: Euros::from_cents_as_i64(10),
|
||||
annual_nominal_rate: Decimal::new(45, 2),
|
||||
borrowed_capital: Euros::from_cents(10),
|
||||
annual_rate_in_percent: Decimal::new(45, 2),
|
||||
loan_duration_in_months: 15
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_loan_offer_with_limit_inputs_works() {
|
||||
let ret = LoanOffer::new(
|
||||
MAX_BORROWED_CAPITAL,
|
||||
MAX_ANNUAL_NOMINAL_RATE,
|
||||
MAX_LOAN_DURATION_IN_MONTHS,
|
||||
);
|
||||
assert!(ret.is_ok());
|
||||
// Should not panic - checks that expect assertion is valid
|
||||
ret.unwrap().monthly_repayment();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_loan_offer_with_null_interest_rate_works() {
|
||||
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::ZERO, 15);
|
||||
let ret = LoanOffer::new(Euros::from_cents(10), Decimal::ZERO, 15);
|
||||
assert_eq!(
|
||||
ret,
|
||||
Ok(LoanOffer {
|
||||
borrowed_capital: Euros::from_cents_as_i64(10),
|
||||
annual_nominal_rate: Decimal::ZERO,
|
||||
borrowed_capital: Euros::from_cents(10),
|
||||
annual_rate_in_percent: Decimal::ZERO,
|
||||
loan_duration_in_months: 15
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_to_annual_nominal_rate_in_percent_works() {
|
||||
let offer = LoanOffer::new(
|
||||
Euros::from_cents_as_i64(150_000_00),
|
||||
Decimal::new(4, 4),
|
||||
300,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(4, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monthly_repayment_works_for_standard_loan_offer() {
|
||||
let fixture = offre_a();
|
||||
let offer = LoanOffer::new(
|
||||
fixture.borrowed_capital,
|
||||
fixture.annual_nominal_rate,
|
||||
fixture.total_duration_in_months,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(offer.monthly_repayment(), fixture.monthly_repayment);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monthly_repayment_works_for_zero_annual_nominal_rate() {
|
||||
let fixture = offre_c_ptz();
|
||||
let offer = LoanOffer::new(
|
||||
fixture.borrowed_capital,
|
||||
fixture.annual_nominal_rate,
|
||||
fixture.total_duration_in_months,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(offer.monthly_repayment(), fixture.monthly_repayment);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue