diff --git a/CLAUDE.md b/CLAUDE.md index 53c564a..6015c86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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()`, `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 example stated in the test name or a comment above it. - Test the amortisation schedule against hand-checked values, never against the implementation's diff --git a/crates/immo-core/Cargo.toml b/crates/immo-core/Cargo.toml index 351117f..24c2337 100644 --- a/crates/immo-core/Cargo.toml +++ b/crates/immo-core/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] -rust_decimal = "1.42.1" +rust_decimal = { version = "1.42.1", features = ["maths"] } thiserror = "2.0.19" [lints.clippy] diff --git a/crates/immo-core/src/loan_offer.rs b/crates/immo-core/src/loan_offer.rs index 3ac886a..5e5345d 100644 --- a/crates/immo-core/src/loan_offer.rs +++ b/crates/immo-core/src/loan_offer.rs @@ -1,4 +1,5 @@ use rust_decimal::Decimal; +use rust_decimal::MathematicalOps; use thiserror::Error; use crate::euros::Euros; @@ -69,11 +70,31 @@ impl LoanOffer { 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() { @@ -169,6 +190,18 @@ mod tests { ); } + #[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); @@ -192,4 +225,30 @@ mod tests { .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); + } }