Compare commits

..

No commits in common. "8d00f6a936e93593b5034dce2fbd451b59bb9a27" and "85b0836d6e0eeaa5cbf7fa72a1bac05eb98f64b1" have entirely different histories.

7 changed files with 33 additions and 6130 deletions

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -1 +0,0 @@
pub mod loan_offer_fixture;

View file

@ -1,5 +1,2 @@
pub mod euros;
pub mod loan_offer;
#[cfg(test)]
pub mod fixtures;

View file

@ -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);
}
}