From 91f96e7b57590ad3cad860fa8a8a7f4766c772a9 Mon Sep 17 00:00:00 2001 From: Marcel Enguehard Date: Thu, 30 Jul 2026 15:09:49 +0200 Subject: [PATCH] Add LoanOffer with validating constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoanOffer::new is the only way in: fields are private and the constructor rejects a negative rate, a zero duration, and a non-positive capital. A zero rate is accepted on purpose — the PTZ (prêt à taux zéro) is a real French instrument. Add thiserror for the typed error, as required for immo-core. Rate and duration take no upper bound for now. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 21 ++++++ crates/immo-core/Cargo.toml | 1 + crates/immo-core/src/lib.rs | 1 + crates/immo-core/src/loan_offer.rs | 113 +++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 crates/immo-core/src/loan_offer.rs diff --git a/Cargo.lock b/Cargo.lock index 0424a71..4b7c493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,6 +174,7 @@ name = "immo-core" version = "0.1.0" dependencies = [ "rust_decimal", + "thiserror", ] [[package]] @@ -492,6 +493,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tinyvec" version = "1.12.0" diff --git a/crates/immo-core/Cargo.toml b/crates/immo-core/Cargo.toml index 6290a9b..351117f 100644 --- a/crates/immo-core/Cargo.toml +++ b/crates/immo-core/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true [dependencies] rust_decimal = "1.42.1" +thiserror = "2.0.19" [lints.clippy] inconsistent_digit_grouping = "allow" diff --git a/crates/immo-core/src/lib.rs b/crates/immo-core/src/lib.rs index b2c4d37..2ad5439 100644 --- a/crates/immo-core/src/lib.rs +++ b/crates/immo-core/src/lib.rs @@ -1 +1,2 @@ pub mod euros; +pub mod loan_offer; diff --git a/crates/immo-core/src/loan_offer.rs b/crates/immo-core/src/loan_offer.rs new file mode 100644 index 0000000..346e549 --- /dev/null +++ b/crates/immo-core/src/loan_offer.rs @@ -0,0 +1,113 @@ +use rust_decimal::Decimal; +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}%")] + NegativeInterestRate(Decimal), + #[error("loan duration cannot be zero")] + ZeroLoanDuration, + #[error("borrowed capital should be strictly positive: {0}")] + NonPositiveBorrowedCapital(Euros), +} + +#[derive(Debug, PartialEq, Eq)] +pub struct LoanOffer { + borrowed_capital: Euros, + annual_rate_in_percent: Decimal, + loan_duration_in_months: u64, +} + +impl LoanOffer { + pub fn new( + borrowed_capital: Euros, + annual_rate_in_percent: Decimal, + loan_duration_in_months: u64, + ) -> Result { + 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 borrowed_capital <= Euros::from_cents(0) { + Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital)) + } else { + Ok(LoanOffer { + borrowed_capital, + annual_rate_in_percent, + loan_duration_in_months, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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); + assert_eq!( + ret, + Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2))) + ); + } + + #[test] + 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); + 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(-1), Decimal::new(45, 2), 15); + assert_eq!( + ret, + Err(LoanOfferError::NonPositiveBorrowedCapital( + Euros::from_cents(-1) + )) + ); + } + + #[test] + 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); + assert_eq!( + ret, + Err(LoanOfferError::NonPositiveBorrowedCapital( + Euros::from_cents(0) + )) + ); + } + + #[test] + fn creating_loan_offer_with_correct_inputs_works() { + let ret = LoanOffer::new(Euros::from_cents(10), Decimal::new(45, 2), 15); + assert_eq!( + ret, + Ok(LoanOffer { + 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_null_interest_rate_works() { + let ret = LoanOffer::new(Euros::from_cents(10), Decimal::ZERO, 15); + assert_eq!( + ret, + Ok(LoanOffer { + borrowed_capital: Euros::from_cents(10), + annual_rate_in_percent: Decimal::ZERO, + loan_duration_in_months: 15 + }) + ); + } +}