Add LoanOffer with validating constructor

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 <noreply@anthropic.com>
This commit is contained in:
Marcel Enguehard 2026-07-30 15:09:49 +02:00
parent f3d428dcbe
commit 91f96e7b57
4 changed files with 136 additions and 0 deletions

21
Cargo.lock generated
View file

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

View file

@ -6,6 +6,7 @@ rust-version.workspace = true
[dependencies]
rust_decimal = "1.42.1"
thiserror = "2.0.19"
[lints.clippy]
inconsistent_digit_grouping = "allow"

View file

@ -1 +1,2 @@
pub mod euros;
pub mod loan_offer;

View file

@ -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<Self, LoanOfferError> {
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
})
);
}
}