Compare commits
7 commits
85b0836d6e
...
8d00f6a936
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d00f6a936 | ||
|
|
a66e13d5db | ||
|
|
bf5c463e93 | ||
|
|
70086389f6 | ||
|
|
3ab4a4064a | ||
|
|
0cf311bdf4 | ||
|
|
4506231f02 |
7 changed files with 6130 additions and 33 deletions
|
|
@ -68,7 +68,7 @@ docs.rs for that exact version.
|
||||||
## Error handling and tests
|
## Error handling and tests
|
||||||
|
|
||||||
- `thiserror` for typed errors in `immo-core`; `anyhow` only at the `immo-web` boundary.
|
- `thiserror` for typed errors in `immo-core`; `anyhow` only at the `immo-web` boundary.
|
||||||
- No `unwrap()`, `expect()`, or `panic!()` outside tests and `main`.
|
- No `unwrap()` or `panic!()` outside tests and `main`. `expect()` can only be used for cases where the Result will provably be `Ok()`
|
||||||
- Every `core` function producing a number the user sees has a known-answer test, with the worked
|
- 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.
|
example stated in the test name or a comment above it.
|
||||||
- Test the amortisation schedule against hand-checked values, never against the implementation's
|
- Test the amortisation schedule against hand-checked values, never against the implementation's
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust_decimal = "1.42.1"
|
rust_decimal = { version = "1.42.1", features = ["maths"] }
|
||||||
thiserror = "2.0.19"
|
thiserror = "2.0.19"
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
use std::fmt;
|
use std::{fmt, ops::Add};
|
||||||
|
|
||||||
use rust_decimal::Decimal;
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)]
|
#[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)]
|
||||||
pub struct Euros {
|
pub struct Euros {
|
||||||
|
|
@ -8,10 +17,22 @@ pub struct Euros {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Euros {
|
impl Euros {
|
||||||
pub const fn from_cents(value_as_cents: i64) -> Self {
|
pub const fn from_cents_as_i64(value_as_cents: i64) -> Self {
|
||||||
Euros { value_as_cents }
|
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 {
|
pub const fn as_cents(self) -> i64 {
|
||||||
self.value_as_cents
|
self.value_as_cents
|
||||||
}
|
}
|
||||||
|
|
@ -27,19 +48,29 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_cents_to_euros_to_cents_stays_equals() {
|
fn from_cents_to_euros_to_cents_stays_equals() {
|
||||||
let euros = Euros::from_cents(123_456_00);
|
let euros = Euros::from_cents_as_i64(123_456_00);
|
||||||
assert_eq!(euros.as_cents(), 123_456_00);
|
assert_eq!(euros.as_cents(), 123_456_00);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_positive_cents_to_decimal_has_scale_2_and_correct_mantissa() {
|
fn from_positive_cents_to_decimal_has_scale_2_and_correct_mantissa() {
|
||||||
let euros = Euros::from_cents(123_456_78);
|
let euros = Euros::from_cents_as_i64(123_456_78);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
euros.as_decimal().scale(),
|
euros.as_decimal().scale(),
|
||||||
2,
|
2,
|
||||||
|
|
@ -54,7 +85,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_negative_cents_to_decimal_has_correct_scale_and_mantissa() {
|
fn from_negative_cents_to_decimal_has_correct_scale_and_mantissa() {
|
||||||
let euros = Euros::from_cents(-123_456_78);
|
let euros = Euros::from_cents_as_i64(-123_456_78);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
euros.as_decimal().scale(),
|
euros.as_decimal().scale(),
|
||||||
2,
|
2,
|
||||||
|
|
@ -69,7 +100,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_max_i64_cents_to_decimal_has_correct_scale_and_mantissa() {
|
fn from_max_i64_cents_to_decimal_has_correct_scale_and_mantissa() {
|
||||||
let euros = Euros::from_cents(i64::MAX);
|
let euros = Euros::from_cents_as_i64(i64::MAX);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
euros.as_decimal().scale(),
|
euros.as_decimal().scale(),
|
||||||
2,
|
2,
|
||||||
|
|
@ -84,26 +115,59 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_0_cents_to_decimal_stays_zero() {
|
fn from_0_cents_to_decimal_stays_zero() {
|
||||||
let euros = Euros::from_cents(0);
|
let euros = Euros::from_cents_as_i64(0);
|
||||||
assert_eq!(euros.as_decimal().scale(), 2);
|
assert_eq!(euros.as_decimal().scale(), 2);
|
||||||
assert_eq!(euros.as_decimal(), Decimal::ZERO);
|
assert_eq!(euros.as_decimal(), Decimal::ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standard_euros_prints_correctly() {
|
fn standard_euros_prints_correctly() {
|
||||||
let euros = Euros::from_cents(123_45);
|
let euros = Euros::from_cents_as_i64(123_45);
|
||||||
assert_eq!(format!("{}", euros), "123.45 €");
|
assert_eq!(format!("{}", euros), "123.45 €");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn euros_without_cents_prints_with_trailing_zeros() {
|
fn euros_without_cents_prints_with_trailing_zeros() {
|
||||||
let euros = Euros::from_cents(123_00);
|
let euros = Euros::from_cents_as_i64(123_00);
|
||||||
assert_eq!(format!("{}", euros), "123.00 €");
|
assert_eq!(format!("{}", euros), "123.00 €");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn euros_with_negative_amount_print_with_minus() {
|
fn euros_with_negative_amount_prints_with_minus() {
|
||||||
let euros = Euros::from_cents(-123_00);
|
let euros = Euros::from_cents_as_i64(-123_00);
|
||||||
assert_eq!(format!("{}", euros), "-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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5888
crates/immo-core/src/fixtures/loan_offer_fixture.rs
Normal file
5888
crates/immo-core/src/fixtures/loan_offer_fixture.rs
Normal file
File diff suppressed because it is too large
Load diff
1
crates/immo-core/src/fixtures/mod.rs
Normal file
1
crates/immo-core/src/fixtures/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod loan_offer_fixture;
|
||||||
|
|
@ -1,2 +1,5 @@
|
||||||
pub mod euros;
|
pub mod euros;
|
||||||
pub mod loan_offer;
|
pub mod loan_offer;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod fixtures;
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,104 @@
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
use rust_decimal::MathematicalOps;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::euros::Euros;
|
use crate::euros::Euros;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Error, Clone)]
|
#[derive(Debug, PartialEq, Eq, Error, Clone)]
|
||||||
pub enum LoanOfferError {
|
pub enum LoanOfferError {
|
||||||
#[error("loan cannot have a negative interest rate: {0}%")]
|
#[error("loan cannot have a negative interest rate: {0}")]
|
||||||
NegativeInterestRate(Decimal),
|
NegativeInterestRate(Decimal),
|
||||||
#[error("loan duration cannot be zero")]
|
#[error("loan duration cannot be zero")]
|
||||||
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,
|
||||||
annual_rate_in_percent: Decimal,
|
annual_nominal_rate: Decimal,
|
||||||
loan_duration_in_months: u64,
|
loan_duration_in_months: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoanOffer {
|
impl LoanOffer {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
borrowed_capital: Euros,
|
borrowed_capital: Euros,
|
||||||
annual_rate_in_percent: Decimal,
|
annual_nominal_rate: Decimal,
|
||||||
loan_duration_in_months: u64,
|
loan_duration_in_months: u64,
|
||||||
) -> Result<Self, LoanOfferError> {
|
) -> Result<Self, LoanOfferError> {
|
||||||
if annual_rate_in_percent < Decimal::ZERO {
|
if annual_nominal_rate < Decimal::ZERO {
|
||||||
Err(LoanOfferError::NegativeInterestRate(annual_rate_in_percent))
|
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 borrowed_capital <= Euros::from_cents(0) {
|
} 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))
|
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,
|
||||||
annual_rate_in_percent,
|
annual_nominal_rate,
|
||||||
loan_duration_in_months,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::fixtures::loan_offer_fixture::*;
|
||||||
|
|
||||||
#[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(123), Decimal::new(-45, 2), 5);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(-45, 2), 5);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ret,
|
ret,
|
||||||
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
|
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
|
||||||
|
|
@ -57,18 +107,60 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creating_loan_offer_with_null_loan_duration_returns_zero_loan_duration_error() {
|
fn creating_loan_offer_with_null_loan_duration_returns_zero_loan_duration_error() {
|
||||||
let ret = LoanOffer::new(Euros::from_cents(123), Decimal::new(45, 2), 0);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(45, 2), 0);
|
||||||
assert_eq!(ret, Err(LoanOfferError::ZeroLoanDuration));
|
assert_eq!(ret, Err(LoanOfferError::ZeroLoanDuration));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creating_loan_offer_with_negative_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
fn creating_loan_offer_with_negative_borrowed_capital_returns_non_positive_borrowed_capital_error()
|
||||||
{
|
{
|
||||||
let ret = LoanOffer::new(Euros::from_cents(-1), Decimal::new(45, 2), 15);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(-1), Decimal::new(45, 2), 15);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ret,
|
ret,
|
||||||
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
||||||
Euros::from_cents(-1)
|
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
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -76,38 +168,87 @@ mod tests {
|
||||||
#[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()
|
||||||
{
|
{
|
||||||
let ret = LoanOffer::new(Euros::from_cents(0), Decimal::new(45, 2), 15);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(0), Decimal::new(45, 2), 15);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ret,
|
ret,
|
||||||
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
Err(LoanOfferError::NonPositiveBorrowedCapital(
|
||||||
Euros::from_cents(0)
|
Euros::from_cents_as_i64(0)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(10), Decimal::new(45, 2), 15);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::new(45, 2), 15);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ret,
|
ret,
|
||||||
Ok(LoanOffer {
|
Ok(LoanOffer {
|
||||||
borrowed_capital: Euros::from_cents(10),
|
borrowed_capital: Euros::from_cents_as_i64(10),
|
||||||
annual_rate_in_percent: Decimal::new(45, 2),
|
annual_nominal_rate: Decimal::new(45, 2),
|
||||||
loan_duration_in_months: 15
|
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]
|
#[test]
|
||||||
fn creating_loan_offer_with_null_interest_rate_works() {
|
fn creating_loan_offer_with_null_interest_rate_works() {
|
||||||
let ret = LoanOffer::new(Euros::from_cents(10), Decimal::ZERO, 15);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::ZERO, 15);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ret,
|
ret,
|
||||||
Ok(LoanOffer {
|
Ok(LoanOffer {
|
||||||
borrowed_capital: Euros::from_cents(10),
|
borrowed_capital: Euros::from_cents_as_i64(10),
|
||||||
annual_rate_in_percent: Decimal::ZERO,
|
annual_nominal_rate: Decimal::ZERO,
|
||||||
loan_duration_in_months: 15
|
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