Compare commits

..

2 commits

Author SHA1 Message Date
Marcel Enguehard
8bf701e010 Expose the amortising base in the loan offer fixtures
The fixture struct published borrowed_capital and total_duration_in_months, but
monthly_repayment is not a function of those two. It is a function of the
capital the amortisation actually runs on and the number of amortising
instalments, and both differ from the published fields as soon as there is a
deferral.

On offre B the six months of total deferral capitalise 439 000 EUR into
444 405,27 EUR, amortised over 294 instalments rather than 300. Feeding the
formula with the fields the fixture exposed gave 1 958,39 EUR against the
table's 2 011,86 EUR. The two deferred fixtures were therefore unusable, and
misleading about it: a 53 EUR monthly gap reads like a rounding bug in the
implementation rather than a missing input.

The generator already computed both values and wrote them to the JSON output.
Only render_rust dropped them, so the Rust fixtures were strictly less
informative than the JSON they were generated alongside.

All four fixtures now reproduce their reference instalment to the cent when fed
amortised_capital and amortisation_in_months, including the two deferred offers
that could not be tested at all before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:40:51 +02:00
Marcel Enguehard
6a47fc8ccd 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 <noreply@anthropic.com>
2026-08-25 13:40:06 +02:00
5 changed files with 176 additions and 50 deletions

View file

@ -17,6 +17,7 @@
use rust_decimal::Decimal; use rust_decimal::Decimal;
use crate::euros::Euros; use crate::euros::Euros;
use crate::interest_rate::InterestRate;
/// Une ligne de l'échéancier de référence. /// 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 // Tous les champs ne sont pas encore lus : les jeux de test précèdent les
@ -36,13 +37,27 @@ pub struct MonthlyInstallment {
pub struct LoanOfferFixture { pub struct LoanOfferFixture {
pub label: &'static str, pub label: &'static str,
pub borrowed_capital: Euros, pub borrowed_capital: Euros,
pub annual_nominal_rate: Decimal, pub annual_nominal_rate: InterestRate,
pub total_duration_in_months: u64, pub total_duration_in_months: u64,
pub deferral_in_months: u64, pub deferral_in_months: u64,
/// Capital sur lequel court l'amortissement, après capitalisation des
/// intérêts du différé. Égal à `borrowed_capital` hors différé total.
///
/// C'est **ce** capital, et non `borrowed_capital`, qui alimente la formule
/// de mensualité. Sur l'offre B, le différé total porte les 439 000 EUR
/// empruntés à 444 405,27 EUR amortissables.
pub amortised_capital: Euros,
/// Nombre d'échéances amortissantes, différé exclu. C'est le `n` de la
/// formule de mensualité, à ne pas confondre avec `total_duration_in_months`.
///
/// Alimenter la formule avec `borrowed_capital` et `total_duration_in_months`
/// sur l'offre B donne 1 958,39 EUR au lieu des 2 011,86 EUR du tableau :
/// l'écart est un cumul des deux confusions, pas une dérive d'arrondi.
pub amortisation_in_months: u64,
pub monthly_repayment: Euros, pub monthly_repayment: Euros,
pub total_credit_cost: Euros, pub total_credit_cost: Euros,
pub total_paid: Euros, pub total_paid: Euros,
pub taeg: Decimal, pub taeg: InterestRate,
pub schedule: &'static [MonthlyInstallment], pub schedule: &'static [MonthlyInstallment],
} }
@ -5822,13 +5837,15 @@ pub fn offre_a() -> LoanOfferFixture {
LoanOfferFixture { LoanOfferFixture {
label: "Offre A — 150 000 EUR, 3,41 %, 20 ans, sans différé", label: "Offre A — 150 000 EUR, 3,41 %, 20 ans, sans différé",
borrowed_capital: Euros::from_cents_as_i64(150_000_00), 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, total_duration_in_months: 240,
deferral_in_months: 0, deferral_in_months: 0,
amortised_capital: Euros::from_cents_as_i64(150_000_00),
amortisation_in_months: 240,
monthly_repayment: Euros::from_cents_as_i64(863_02), monthly_repayment: Euros::from_cents_as_i64(863_02),
total_credit_cost: Euros::from_cents_as_i64(57_124_30), total_credit_cost: Euros::from_cents_as_i64(57_124_30),
total_paid: Euros::from_cents_as_i64(207_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, schedule: &OFFRE_A_SCHEDULE,
} }
} }
@ -5837,13 +5854,15 @@ pub fn offre_b_differe_total() -> LoanOfferFixture {
LoanOfferFixture { LoanOfferFixture {
label: "Offre B — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé total", 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), 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, total_duration_in_months: 300,
deferral_in_months: 6, deferral_in_months: 6,
amortised_capital: Euros::from_cents_as_i64(444_405_27),
amortisation_in_months: 294,
monthly_repayment: Euros::from_cents_as_i64(2_011_86), monthly_repayment: Euros::from_cents_as_i64(2_011_86),
total_credit_cost: Euros::from_cents_as_i64(152_486_18), total_credit_cost: Euros::from_cents_as_i64(152_486_18),
total_paid: Euros::from_cents_as_i64(591_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, schedule: &OFFRE_B_DIFFERE_TOTAL_SCHEDULE,
} }
} }
@ -5852,13 +5871,15 @@ pub fn offre_b_differe_partiel() -> LoanOfferFixture {
LoanOfferFixture { LoanOfferFixture {
label: "Offre B' — 439 000 EUR, 2,45 %, 25 ans, 6 mois de différé partiel", 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), 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, total_duration_in_months: 300,
deferral_in_months: 6, deferral_in_months: 6,
amortised_capital: Euros::from_cents_as_i64(439_000_00),
amortisation_in_months: 294,
monthly_repayment: Euros::from_cents_as_i64(1_987_39), monthly_repayment: Euros::from_cents_as_i64(1_987_39),
total_credit_cost: Euros::from_cents_as_i64(150_669_69), total_credit_cost: Euros::from_cents_as_i64(150_669_69),
total_paid: Euros::from_cents_as_i64(589_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, schedule: &OFFRE_B_DIFFERE_PARTIEL_SCHEDULE,
} }
} }
@ -5867,13 +5888,15 @@ pub fn offre_c_ptz() -> LoanOfferFixture {
LoanOfferFixture { LoanOfferFixture {
label: "Offre C — PTZ 95 000 EUR, 0 %, 10 ans, sans différé", label: "Offre C — PTZ 95 000 EUR, 0 %, 10 ans, sans différé",
borrowed_capital: Euros::from_cents_as_i64(95_000_00), 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, total_duration_in_months: 120,
deferral_in_months: 0, deferral_in_months: 0,
amortised_capital: Euros::from_cents_as_i64(95_000_00),
amortisation_in_months: 120,
monthly_repayment: Euros::from_cents_as_i64(791_67), monthly_repayment: Euros::from_cents_as_i64(791_67),
total_credit_cost: Euros::from_cents_as_i64(0_00), total_credit_cost: Euros::from_cents_as_i64(0_00),
total_paid: Euros::from_cents_as_i64(95_000_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, schedule: &OFFRE_C_PTZ_SCHEDULE,
} }
} }

View file

@ -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 %");
}
}

View file

@ -1,4 +1,5 @@
pub mod euros; pub mod euros;
pub mod interest_rate;
pub mod loan_offer; pub mod loan_offer;
#[cfg(test)] #[cfg(test)]

View file

@ -3,11 +3,12 @@ use rust_decimal::MathematicalOps;
use thiserror::Error; use thiserror::Error;
use crate::euros::Euros; use crate::euros::Euros;
use crate::interest_rate::InterestRate;
#[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(InterestRate),
#[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}")]
@ -15,7 +16,7 @@ pub enum LoanOfferError {
#[error("borrowed capital should be lower than {MAX_BORROWED_CAPITAL}: {0}")] #[error("borrowed capital should be lower than {MAX_BORROWED_CAPITAL}: {0}")]
MaxBorrowedCapitalExceeded(Euros), MaxBorrowedCapitalExceeded(Euros),
#[error("annual nominal rate should be lower than {MAX_ANNUAL_NOMINAL_RATE}: {0}")] #[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}")] #[error("loan duration in months should be lower than {MAX_LOAN_DURATION_IN_MONTHS}: {0}")]
MaxLoanDurationExceeded(u64), MaxLoanDurationExceeded(u64),
#[error("loan cannot amortise")] #[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); pub const MAX_BORROWED_CAPITAL: Euros = Euros::from_cents_as_i64(999_999_999_99);
/// 99% nominal rate will hopefully never happen /// 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 /// 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; 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)] #[derive(Debug, PartialEq, Eq)]
pub struct LoanOffer { pub struct LoanOffer {
borrowed_capital: Euros, borrowed_capital: Euros,
annual_nominal_rate: Decimal, annual_nominal_rate: InterestRate,
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_nominal_rate: Decimal, annual_nominal_rate: InterestRate,
loan_duration_in_months: u64, loan_duration_in_months: u64,
) -> Result<Self, LoanOfferError> { ) -> Result<Self, LoanOfferError> {
if annual_nominal_rate < Decimal::ZERO { if annual_nominal_rate < InterestRate::ZERO {
Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate)) Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate))
} else if annual_nominal_rate > MAX_ANNUAL_NOMINAL_RATE { } else if annual_nominal_rate > MAX_ANNUAL_NOMINAL_RATE {
Err(LoanOfferError::MaxAnnualNominalRateExceeded( Err(LoanOfferError::MaxAnnualNominalRateExceeded(
@ -79,12 +81,8 @@ impl LoanOffer {
} }
} }
pub fn annual_nominal_rate_in_percent(&self) -> Decimal { fn proportional_monthly_interest_rate(&self) -> Decimal {
self.annual_nominal_rate * Decimal::ONE_HUNDRED self.annual_nominal_rate.as_fraction() / Decimal::from(12)
}
pub fn proportional_monthly_interest_rate(&self) -> Decimal {
self.annual_nominal_rate / Decimal::from(12)
} }
pub fn monthly_repayment(&self) -> Euros { pub fn monthly_repayment(&self) -> Euros {
@ -110,23 +108,32 @@ mod tests {
#[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_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!( assert_eq!(
ret, ret,
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2))) Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate))
); );
} }
#[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_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)); 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_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!( assert_eq!(
ret, ret,
Err(LoanOfferError::NonPositiveBorrowedCapital( 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() 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 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))); assert_eq!(ret, Err(LoanOfferError::MaxBorrowedCapitalExceeded(euros)));
} }
#[test] #[test]
fn creating_loan_offer_with_annual_nominal_rate_higher_than_max_annual_nominal_rate_returns_max_annual_nominal_rate_exceeded_error() 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( let ret = LoanOffer::new(
Euros::from_cents_as_i64(100_000_00), Euros::from_cents_as_i64(100_000_00),
annual_nominal_rate, annual_nominal_rate,
@ -166,7 +178,7 @@ mod tests {
let loan_duration_in_months = MAX_LOAN_DURATION_IN_MONTHS + 1; let loan_duration_in_months = MAX_LOAN_DURATION_IN_MONTHS + 1;
let ret = LoanOffer::new( let ret = LoanOffer::new(
Euros::from_cents_as_i64(100_000_00), Euros::from_cents_as_i64(100_000_00),
Decimal::new(314, 4), InterestRate::from_percentage(Decimal::new(314, 2)),
loan_duration_in_months, loan_duration_in_months,
); );
assert_eq!( assert_eq!(
@ -180,7 +192,11 @@ 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_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!( assert_eq!(
ret, ret,
Err(LoanOfferError::NonPositiveBorrowedCapital( Err(LoanOfferError::NonPositiveBorrowedCapital(
@ -193,7 +209,7 @@ mod tests {
fn creating_loan_offer_that_cannot_amortise_returns_loan_cannot_amortise_error() { fn creating_loan_offer_that_cannot_amortise_returns_loan_cannot_amortise_error() {
let ret = LoanOffer::new( let ret = LoanOffer::new(
Euros::from_cents_as_i64(439_000_00), Euros::from_cents_as_i64(439_000_00),
Decimal::new(99, 2), InterestRate::from_percentage(Decimal::from(99)),
480, 480,
); );
assert_eq!(ret, Err(LoanOfferError::LoanCannotAmortise)); assert_eq!(ret, Err(LoanOfferError::LoanCannotAmortise));
@ -201,12 +217,13 @@ mod tests {
#[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_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!( assert_eq!(
ret, ret,
Ok(LoanOffer { Ok(LoanOffer {
borrowed_capital: Euros::from_cents_as_i64(10), borrowed_capital: Euros::from_cents_as_i64(10),
annual_nominal_rate: Decimal::new(45, 2), annual_nominal_rate,
loan_duration_in_months: 15 loan_duration_in_months: 15
}) })
); );
@ -222,28 +239,17 @@ mod tests {
#[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_as_i64(10), Decimal::ZERO, 15); let ret = LoanOffer::new(Euros::from_cents_as_i64(10), InterestRate::ZERO, 15);
assert_eq!( assert_eq!(
ret, ret,
Ok(LoanOffer { Ok(LoanOffer {
borrowed_capital: Euros::from_cents_as_i64(10), borrowed_capital: Euros::from_cents_as_i64(10),
annual_nominal_rate: Decimal::ZERO, annual_nominal_rate: InterestRate::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, 2),
300,
)
.unwrap();
assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(400, 2));
}
#[test] #[test]
fn monthly_repayment_works_for_standard_loan_offer() { fn monthly_repayment_works_for_standard_loan_offer() {
let fixture = offre_a(); let fixture = offre_a();

View file

@ -552,6 +552,7 @@ RUST_HEADER = '''\
use rust_decimal::Decimal; use rust_decimal::Decimal;
use crate::euros::Euros; use crate::euros::Euros;
use crate::interest_rate::InterestRate;
/// Une ligne de l'échéancier de référence. /// 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 // Tous les champs ne sont pas encore lus : les jeux de test précèdent les
@ -571,13 +572,27 @@ pub struct MonthlyInstallment {{
pub struct LoanOfferFixture {{ pub struct LoanOfferFixture {{
pub label: &'static str, pub label: &'static str,
pub borrowed_capital: Euros, pub borrowed_capital: Euros,
pub annual_nominal_rate: Decimal, pub annual_nominal_rate: InterestRate,
pub total_duration_in_months: u64, pub total_duration_in_months: u64,
pub deferral_in_months: u64, pub deferral_in_months: u64,
/// Capital sur lequel court l'amortissement, après capitalisation des
/// intérêts du différé. Égal à `borrowed_capital` hors différé total.
///
/// C'est **ce** capital, et non `borrowed_capital`, qui alimente la formule
/// de mensualité. Sur l'offre B, le différé total porte les 439 000 EUR
/// empruntés à 444 405,27 EUR amortissables.
pub amortised_capital: Euros,
/// Nombre d'échéances amortissantes, différé exclu. C'est le `n` de la
/// formule de mensualité, à ne pas confondre avec `total_duration_in_months`.
///
/// Alimenter la formule avec `borrowed_capital` et `total_duration_in_months`
/// sur l'offre B donne 1 958,39 EUR au lieu des 2 011,86 EUR du tableau :
/// l'écart est un cumul des deux confusions, pas une dérive d'arrondi.
pub amortisation_in_months: u64,
pub monthly_repayment: Euros, pub monthly_repayment: Euros,
pub total_credit_cost: Euros, pub total_credit_cost: Euros,
pub total_paid: Euros, pub total_paid: Euros,
pub taeg: Decimal, pub taeg: InterestRate,
pub schedule: &'static [MonthlyInstallment], pub schedule: &'static [MonthlyInstallment],
}} }}
''' '''
@ -619,6 +634,12 @@ def rust_decimal(value: Decimal) -> str:
return f"Decimal::new({mantissa}, {-exponent})" 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: def render_rust(offers: list[LoanOffer], path: str) -> str:
chunks = [RUST_HEADER.format(path=path)] chunks = [RUST_HEADER.format(path=path)]
@ -647,14 +668,16 @@ def render_rust(offers: list[LoanOffer], path: str) -> str:
" LoanOfferFixture {\n" " LoanOfferFixture {\n"
f" label: {rust_string(offer.label)},\n" f" label: {rust_string(offer.label)},\n"
f" borrowed_capital: {rust_euros(offer.borrowed_capital)},\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" total_duration_in_months: {offer.total_duration_in_months},\n"
f" deferral_in_months: {offer.deferral_in_months},\n" f" deferral_in_months: {offer.deferral_in_months},\n"
f" amortised_capital: {rust_euros(offer.amortised_capital)},\n"
f" amortisation_in_months: {offer.amortisation_in_months},\n"
f" monthly_repayment: {rust_euros(offer.monthly_repayment)},\n" f" monthly_repayment: {rust_euros(offer.monthly_repayment)},\n"
f" total_credit_cost: {rust_euros(offer.total_credit_cost(rows))},\n" f" total_credit_cost: {rust_euros(offer.total_credit_cost(rows))},\n"
f" total_paid: {rust_euros(offer.total_paid(rows))},\n" f" total_paid: {rust_euros(offer.total_paid(rows))},\n"
" taeg: " " 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" f" schedule: &{offer.id.upper()}_SCHEDULE,\n"
" }\n" " }\n"
"}\n" "}\n"