From 6a47fc8ccdfacf784dcc6d7703eaa6258cca8690 Mon Sep 17 00:00:00 2001 From: Marcel Enguehard Date: Tue, 25 Aug 2026 13:40:06 +0200 Subject: [PATCH] Introduce InterestRate so a rate carries its unit A bare Decimal rate is ambiguous between the fraction 0,0341 and the percentage 3,41, and nothing in the type system separated them. from_fraction and from_percentage force the call site to say which one it means. Migrating the call sites showed the ambiguity was already there: several tests passed Decimal::new(45, 2), which the old code consumed as the fraction 0,45, that is 45 %, while it reads naturally as 0,45 %. Those tests exercise capital and duration rather than the rate, so their outcome is unchanged, but the reading was a coin flip before and is now explicit. The type deliberately validates nothing. Comparing buying against investing the same cash needs rates for expected return and inflation, where a negative value is meaningful and a 99 % ceiling is nonsense. Range stays a LoanOffer rule, which is where the domain knows what a plausible loan rate is. LoanOffer::annual_nominal_rate_in_percent is replaced by InterestRate:: as_percentage, so the conversion stays in immo-core instead of immo-web having to multiply by 100 itself. It normalises: dividing by 100 and multiplying back does not restore the original scale, so 3,41 % round-tripped through Display as "3.4100 %". This fixes the fraction versus percentage axis only. InterestRate carries no period, so proportional_monthly_interest_rate still returns a bare Decimal and naming alone separates annual from monthly. Co-Authored-By: Claude Opus 5 --- .../src/fixtures/loan_offer_fixture.rs | 21 ++--- crates/immo-core/src/interest_rate.rs | 73 +++++++++++++++++ crates/immo-core/src/lib.rs | 1 + crates/immo-core/src/loan_offer.rs | 78 ++++++++++--------- scripts/loan_schedule.py | 15 +++- 5 files changed, 138 insertions(+), 50 deletions(-) create mode 100644 crates/immo-core/src/interest_rate.rs diff --git a/crates/immo-core/src/fixtures/loan_offer_fixture.rs b/crates/immo-core/src/fixtures/loan_offer_fixture.rs index e15b4ae..33f4bca 100644 --- a/crates/immo-core/src/fixtures/loan_offer_fixture.rs +++ b/crates/immo-core/src/fixtures/loan_offer_fixture.rs @@ -17,6 +17,7 @@ use rust_decimal::Decimal; use crate::euros::Euros; +use crate::interest_rate::InterestRate; /// Une ligne de l'échéancier de référence. // Tous les champs ne sont pas encore lus : les jeux de test précèdent les @@ -36,13 +37,13 @@ pub struct MonthlyInstallment { pub struct LoanOfferFixture { pub label: &'static str, pub borrowed_capital: Euros, - pub annual_nominal_rate: Decimal, + pub annual_nominal_rate: InterestRate, pub total_duration_in_months: u64, pub deferral_in_months: u64, pub monthly_repayment: Euros, pub total_credit_cost: Euros, pub total_paid: Euros, - pub taeg: Decimal, + pub taeg: InterestRate, pub schedule: &'static [MonthlyInstallment], } @@ -5822,13 +5823,13 @@ pub fn offre_a() -> LoanOfferFixture { LoanOfferFixture { label: "Offre A — 150 000 EUR, 3,41 %, 20 ans, sans différé", borrowed_capital: Euros::from_cents_as_i64(150_000_00), - annual_nominal_rate: Decimal::new(341, 4), + annual_nominal_rate: InterestRate::from_fraction(Decimal::new(341, 4)), total_duration_in_months: 240, deferral_in_months: 0, monthly_repayment: Euros::from_cents_as_i64(863_02), total_credit_cost: Euros::from_cents_as_i64(57_124_30), total_paid: Euros::from_cents_as_i64(207_124_30), - taeg: Decimal::new(3463804, 8), + taeg: InterestRate::from_fraction(Decimal::new(3463804, 8)), schedule: &OFFRE_A_SCHEDULE, } } @@ -5837,13 +5838,13 @@ pub fn offre_b_differe_total() -> LoanOfferFixture { LoanOfferFixture { label: "Offre B — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé total", borrowed_capital: Euros::from_cents_as_i64(439_000_00), - annual_nominal_rate: Decimal::new(245, 4), + annual_nominal_rate: InterestRate::from_fraction(Decimal::new(245, 4)), total_duration_in_months: 300, deferral_in_months: 6, monthly_repayment: Euros::from_cents_as_i64(2_011_86), total_credit_cost: Euros::from_cents_as_i64(152_486_18), total_paid: Euros::from_cents_as_i64(591_486_18), - taeg: Decimal::new(2477699, 8), + taeg: InterestRate::from_fraction(Decimal::new(2477699, 8)), schedule: &OFFRE_B_DIFFERE_TOTAL_SCHEDULE, } } @@ -5852,13 +5853,13 @@ pub fn offre_b_differe_partiel() -> LoanOfferFixture { LoanOfferFixture { label: "Offre B' — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé partiel", borrowed_capital: Euros::from_cents_as_i64(439_000_00), - annual_nominal_rate: Decimal::new(245, 4), + annual_nominal_rate: InterestRate::from_fraction(Decimal::new(245, 4)), total_duration_in_months: 300, deferral_in_months: 6, monthly_repayment: Euros::from_cents_as_i64(1_987_39), total_credit_cost: Euros::from_cents_as_i64(150_669_69), total_paid: Euros::from_cents_as_i64(589_669_69), - taeg: Decimal::new(2477699, 8), + taeg: InterestRate::from_fraction(Decimal::new(2477699, 8)), schedule: &OFFRE_B_DIFFERE_PARTIEL_SCHEDULE, } } @@ -5867,13 +5868,13 @@ pub fn offre_c_ptz() -> LoanOfferFixture { LoanOfferFixture { label: "Offre C — PTZ 95 000 EUR, 0 %, 10 ans, sans différé", borrowed_capital: Euros::from_cents_as_i64(95_000_00), - annual_nominal_rate: Decimal::new(0, 0), + annual_nominal_rate: InterestRate::from_fraction(Decimal::new(0, 0)), total_duration_in_months: 120, deferral_in_months: 0, monthly_repayment: Euros::from_cents_as_i64(791_67), total_credit_cost: Euros::from_cents_as_i64(0_00), total_paid: Euros::from_cents_as_i64(95_000_00), - taeg: Decimal::new(0, 8), + taeg: InterestRate::from_fraction(Decimal::new(0, 8)), schedule: &OFFRE_C_PTZ_SCHEDULE, } } diff --git a/crates/immo-core/src/interest_rate.rs b/crates/immo-core/src/interest_rate.rs new file mode 100644 index 0000000..626a66f --- /dev/null +++ b/crates/immo-core/src/interest_rate.rs @@ -0,0 +1,73 @@ +use std::fmt::Display; + +use rust_decimal::Decimal; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord)] +pub struct InterestRate { + rate_as_fraction: Decimal, +} + +impl InterestRate { + pub const ZERO: Self = InterestRate { + rate_as_fraction: Decimal::ZERO, + }; + + pub fn from_percentage(rate_in_percent: Decimal) -> Self { + InterestRate { + rate_as_fraction: rate_in_percent / Decimal::ONE_HUNDRED, + } + } + + pub const fn from_fraction(rate_as_fraction: Decimal) -> Self { + InterestRate { rate_as_fraction } + } + + pub const fn as_fraction(self) -> Decimal { + self.rate_as_fraction + } + + pub fn as_percentage(self) -> Decimal { + (self.rate_as_fraction * Decimal::ONE_HUNDRED).normalize() + } +} + +impl Display for InterestRate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} %", self.as_percentage()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_from_percentage_yields_correct_rate() { + assert_eq!( + InterestRate::from_percentage(Decimal::new(415, 2)).as_fraction(), + Decimal::new(415, 4) + ); + } + + #[test] + fn create_from_fraction_yields_correct_rate() { + assert_eq!( + InterestRate::from_fraction(Decimal::new(415, 4)).as_fraction(), + Decimal::new(415, 4) + ); + } + + #[test] + fn create_from_fraction_is_properly_output_as_percentage() { + assert_eq!( + InterestRate::from_fraction(Decimal::new(314, 4)).as_percentage(), + Decimal::new(314, 2) + ) + } + + #[test] + fn display_works_as_expected_with_no_trailing_zeros() { + let interest_rate = InterestRate::from_fraction(Decimal::new(3145, 5)); + assert_eq!(format!("{}", interest_rate), "3.145 %"); + } +} diff --git a/crates/immo-core/src/lib.rs b/crates/immo-core/src/lib.rs index 3082eac..62dd2ec 100644 --- a/crates/immo-core/src/lib.rs +++ b/crates/immo-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod euros; +pub mod interest_rate; pub mod loan_offer; #[cfg(test)] diff --git a/crates/immo-core/src/loan_offer.rs b/crates/immo-core/src/loan_offer.rs index d5847c0..ab75ac8 100644 --- a/crates/immo-core/src/loan_offer.rs +++ b/crates/immo-core/src/loan_offer.rs @@ -3,11 +3,12 @@ use rust_decimal::MathematicalOps; use thiserror::Error; use crate::euros::Euros; +use crate::interest_rate::InterestRate; #[derive(Debug, PartialEq, Eq, Error, Clone)] pub enum LoanOfferError { #[error("loan cannot have a negative interest rate: {0}")] - NegativeInterestRate(Decimal), + NegativeInterestRate(InterestRate), #[error("loan duration cannot be zero")] ZeroLoanDuration, #[error("borrowed capital should be strictly positive: {0}")] @@ -15,7 +16,7 @@ pub enum LoanOfferError { #[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), + MaxAnnualNominalRateExceeded(InterestRate), #[error("loan duration in months should be lower than {MAX_LOAN_DURATION_IN_MONTHS}: {0}")] MaxLoanDurationExceeded(u64), #[error("loan cannot amortise")] @@ -26,7 +27,8 @@ pub enum LoanOfferError { 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); +pub const MAX_ANNUAL_NOMINAL_RATE: InterestRate = + InterestRate::from_fraction(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; @@ -34,17 +36,17 @@ 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_nominal_rate: InterestRate, loan_duration_in_months: u64, } impl LoanOffer { pub fn new( borrowed_capital: Euros, - annual_nominal_rate: Decimal, + annual_nominal_rate: InterestRate, loan_duration_in_months: u64, ) -> Result { - if annual_nominal_rate < Decimal::ZERO { + if annual_nominal_rate < InterestRate::ZERO { Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate)) } else if annual_nominal_rate > MAX_ANNUAL_NOMINAL_RATE { Err(LoanOfferError::MaxAnnualNominalRateExceeded( @@ -79,12 +81,8 @@ 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) + fn proportional_monthly_interest_rate(&self) -> Decimal { + self.annual_nominal_rate.as_fraction() / Decimal::from(12) } pub fn monthly_repayment(&self) -> Euros { @@ -110,23 +108,32 @@ mod tests { #[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), 60); + let annual_nominal_rate = InterestRate::from_percentage(Decimal::new(-45, 2)); + let ret = LoanOffer::new(Euros::from_cents_as_i64(123), annual_nominal_rate, 60); assert_eq!( ret, - Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2))) + Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate)) ); } #[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_as_i64(123), + InterestRate::from_percentage(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_as_i64(-1), + InterestRate::from_percentage(Decimal::new(45, 2)), + 15, + ); assert_eq!( ret, Err(LoanOfferError::NonPositiveBorrowedCapital( @@ -139,14 +146,19 @@ mod tests { 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); + let ret = LoanOffer::new( + euros, + InterestRate::from_percentage(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 annual_nominal_rate = + InterestRate::from_fraction(MAX_ANNUAL_NOMINAL_RATE.as_fraction() + Decimal::new(1, 4)); let ret = LoanOffer::new( Euros::from_cents_as_i64(100_000_00), annual_nominal_rate, @@ -166,7 +178,7 @@ mod tests { 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), + InterestRate::from_percentage(Decimal::new(314, 2)), loan_duration_in_months, ); assert_eq!( @@ -180,7 +192,11 @@ 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_as_i64(0), + InterestRate::from_percentage(Decimal::new(45, 2)), + 15, + ); assert_eq!( ret, Err(LoanOfferError::NonPositiveBorrowedCapital( @@ -193,7 +209,7 @@ mod tests { fn creating_loan_offer_that_cannot_amortise_returns_loan_cannot_amortise_error() { let ret = LoanOffer::new( Euros::from_cents_as_i64(439_000_00), - Decimal::new(99, 2), + InterestRate::from_percentage(Decimal::from(99)), 480, ); assert_eq!(ret, Err(LoanOfferError::LoanCannotAmortise)); @@ -201,12 +217,13 @@ mod tests { #[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 annual_nominal_rate = InterestRate::from_percentage(Decimal::new(45, 2)); + let ret = LoanOffer::new(Euros::from_cents_as_i64(10), annual_nominal_rate, 15); assert_eq!( ret, Ok(LoanOffer { borrowed_capital: Euros::from_cents_as_i64(10), - annual_nominal_rate: Decimal::new(45, 2), + annual_nominal_rate, loan_duration_in_months: 15 }) ); @@ -222,28 +239,17 @@ mod tests { #[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_as_i64(10), InterestRate::ZERO, 15); assert_eq!( ret, Ok(LoanOffer { borrowed_capital: Euros::from_cents_as_i64(10), - annual_nominal_rate: Decimal::ZERO, + annual_nominal_rate: InterestRate::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, 2), - 300, - ) - .unwrap(); - assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(400, 2)); - } - #[test] fn monthly_repayment_works_for_standard_loan_offer() { let fixture = offre_a(); diff --git a/scripts/loan_schedule.py b/scripts/loan_schedule.py index 3c48a2c..b3754cf 100644 --- a/scripts/loan_schedule.py +++ b/scripts/loan_schedule.py @@ -552,6 +552,7 @@ RUST_HEADER = '''\ use rust_decimal::Decimal; use crate::euros::Euros; +use crate::interest_rate::InterestRate; /// Une ligne de l'échéancier de référence. // Tous les champs ne sont pas encore lus : les jeux de test précèdent les @@ -571,13 +572,13 @@ pub struct MonthlyInstallment {{ pub struct LoanOfferFixture {{ pub label: &'static str, pub borrowed_capital: Euros, - pub annual_nominal_rate: Decimal, + pub annual_nominal_rate: InterestRate, pub total_duration_in_months: u64, pub deferral_in_months: u64, pub monthly_repayment: Euros, pub total_credit_cost: Euros, pub total_paid: Euros, - pub taeg: Decimal, + pub taeg: InterestRate, pub schedule: &'static [MonthlyInstallment], }} ''' @@ -619,6 +620,12 @@ def rust_decimal(value: Decimal) -> str: return f"Decimal::new({mantissa}, {-exponent})" +def rust_interest_rate(value: Decimal) -> str: + """`InterestRate::from_fraction(...)`. Les taux sont stockés en fraction + (0,0341 pour 3,41 %), comme dans `immo-core`.""" + return f"InterestRate::from_fraction({rust_decimal(value)})" + + def render_rust(offers: list[LoanOffer], path: str) -> str: chunks = [RUST_HEADER.format(path=path)] @@ -647,14 +654,14 @@ def render_rust(offers: list[LoanOffer], path: str) -> str: " LoanOfferFixture {\n" f" label: {rust_string(offer.label)},\n" f" borrowed_capital: {rust_euros(offer.borrowed_capital)},\n" - f" annual_nominal_rate: {rust_decimal(offer.annual_nominal_rate)},\n" + f" annual_nominal_rate: {rust_interest_rate(offer.annual_nominal_rate)},\n" f" total_duration_in_months: {offer.total_duration_in_months},\n" f" deferral_in_months: {offer.deferral_in_months},\n" f" monthly_repayment: {rust_euros(offer.monthly_repayment)},\n" f" total_credit_cost: {rust_euros(offer.total_credit_cost(rows))},\n" f" total_paid: {rust_euros(offer.total_paid(rows))},\n" " taeg: " - f"{rust_decimal(offer.taeg(rows).quantize(Decimal('1e-8'), rounding=ROUND_HALF_UP))},\n" + f"{rust_interest_rate(offer.taeg(rows).quantize(Decimal('1e-8'), rounding=ROUND_HALF_UP))},\n" f" schedule: &{offer.id.upper()}_SCHEDULE,\n" " }\n" "}\n"