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>
This commit is contained in:
Marcel Enguehard 2026-08-25 13:40:06 +02:00
parent f1ba015cc3
commit 6a47fc8ccd
5 changed files with 138 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,13 @@ 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,
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 +5823,13 @@ 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,
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 +5838,13 @@ 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,
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 +5853,13 @@ 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,
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 +5868,13 @@ 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,
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,13 @@ 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,
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 +620,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 +654,14 @@ 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" 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"