Compare commits
No commits in common. "8bf701e0108cf71c292a95a5cb0d0e1ff6e04c6c" and "f1ba015cc32fe8390158779723fad9fc1f93a2d6" have entirely different histories.
8bf701e010
...
f1ba015cc3
5 changed files with 50 additions and 176 deletions
|
|
@ -17,7 +17,6 @@
|
||||||
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
|
||||||
|
|
@ -37,27 +36,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: InterestRate,
|
pub annual_nominal_rate: Decimal,
|
||||||
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: InterestRate,
|
pub taeg: Decimal,
|
||||||
pub schedule: &'static [MonthlyInstallment],
|
pub schedule: &'static [MonthlyInstallment],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5837,15 +5822,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: InterestRate::from_fraction(Decimal::new(341, 4)),
|
annual_nominal_rate: 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: InterestRate::from_fraction(Decimal::new(3463804, 8)),
|
taeg: Decimal::new(3463804, 8),
|
||||||
schedule: &OFFRE_A_SCHEDULE,
|
schedule: &OFFRE_A_SCHEDULE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5854,15 +5837,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: InterestRate::from_fraction(Decimal::new(245, 4)),
|
annual_nominal_rate: 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: InterestRate::from_fraction(Decimal::new(2477699, 8)),
|
taeg: Decimal::new(2477699, 8),
|
||||||
schedule: &OFFRE_B_DIFFERE_TOTAL_SCHEDULE,
|
schedule: &OFFRE_B_DIFFERE_TOTAL_SCHEDULE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5871,15 +5852,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: InterestRate::from_fraction(Decimal::new(245, 4)),
|
annual_nominal_rate: 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: InterestRate::from_fraction(Decimal::new(2477699, 8)),
|
taeg: Decimal::new(2477699, 8),
|
||||||
schedule: &OFFRE_B_DIFFERE_PARTIEL_SCHEDULE,
|
schedule: &OFFRE_B_DIFFERE_PARTIEL_SCHEDULE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5888,15 +5867,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: InterestRate::from_fraction(Decimal::new(0, 0)),
|
annual_nominal_rate: 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: InterestRate::from_fraction(Decimal::new(0, 8)),
|
taeg: Decimal::new(0, 8),
|
||||||
schedule: &OFFRE_C_PTZ_SCHEDULE,
|
schedule: &OFFRE_C_PTZ_SCHEDULE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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 %");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
pub mod euros;
|
pub mod euros;
|
||||||
pub mod interest_rate;
|
|
||||||
pub mod loan_offer;
|
pub mod loan_offer;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,11 @@ 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(InterestRate),
|
NegativeInterestRate(Decimal),
|
||||||
#[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}")]
|
||||||
|
|
@ -16,7 +15,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(InterestRate),
|
MaxAnnualNominalRateExceeded(Decimal),
|
||||||
#[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")]
|
||||||
|
|
@ -27,8 +26,7 @@ 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: InterestRate =
|
pub const MAX_ANNUAL_NOMINAL_RATE: Decimal = Decimal::from_parts(99, 0, 0, false, 2);
|
||||||
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;
|
||||||
|
|
@ -36,17 +34,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: InterestRate,
|
annual_nominal_rate: Decimal,
|
||||||
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: InterestRate,
|
annual_nominal_rate: Decimal,
|
||||||
loan_duration_in_months: u64,
|
loan_duration_in_months: u64,
|
||||||
) -> Result<Self, LoanOfferError> {
|
) -> Result<Self, LoanOfferError> {
|
||||||
if annual_nominal_rate < InterestRate::ZERO {
|
if annual_nominal_rate < Decimal::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(
|
||||||
|
|
@ -81,8 +79,12 @@ impl LoanOffer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn proportional_monthly_interest_rate(&self) -> Decimal {
|
pub fn annual_nominal_rate_in_percent(&self) -> Decimal {
|
||||||
self.annual_nominal_rate.as_fraction() / Decimal::from(12)
|
self.annual_nominal_rate * Decimal::ONE_HUNDRED
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
@ -108,32 +110,23 @@ 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 annual_nominal_rate = InterestRate::from_percentage(Decimal::new(-45, 2));
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(-45, 2), 60);
|
||||||
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), annual_nominal_rate, 60);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ret,
|
ret,
|
||||||
Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate))
|
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(45, 2), 0);
|
||||||
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(
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(-1), Decimal::new(45, 2), 15);
|
||||||
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(
|
||||||
|
|
@ -146,19 +139,14 @@ 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(
|
let ret = LoanOffer::new(euros, Decimal::new(45, 2), 25);
|
||||||
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 =
|
let annual_nominal_rate = MAX_ANNUAL_NOMINAL_RATE + Decimal::new(1, 2);
|
||||||
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,
|
||||||
|
|
@ -178,7 +166,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),
|
||||||
InterestRate::from_percentage(Decimal::new(314, 2)),
|
Decimal::new(314, 4),
|
||||||
loan_duration_in_months,
|
loan_duration_in_months,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -192,11 +180,7 @@ 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(
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(0), Decimal::new(45, 2), 15);
|
||||||
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(
|
||||||
|
|
@ -209,7 +193,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),
|
||||||
InterestRate::from_percentage(Decimal::from(99)),
|
Decimal::new(99, 2),
|
||||||
480,
|
480,
|
||||||
);
|
);
|
||||||
assert_eq!(ret, Err(LoanOfferError::LoanCannotAmortise));
|
assert_eq!(ret, Err(LoanOfferError::LoanCannotAmortise));
|
||||||
|
|
@ -217,13 +201,12 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creating_loan_offer_with_correct_inputs_works() {
|
fn creating_loan_offer_with_correct_inputs_works() {
|
||||||
let annual_nominal_rate = InterestRate::from_percentage(Decimal::new(45, 2));
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::new(45, 2), 15);
|
||||||
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,
|
annual_nominal_rate: Decimal::new(45, 2),
|
||||||
loan_duration_in_months: 15
|
loan_duration_in_months: 15
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -239,17 +222,28 @@ 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), InterestRate::ZERO, 15);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::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: InterestRate::ZERO,
|
annual_nominal_rate: Decimal::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();
|
||||||
|
|
|
||||||
|
|
@ -552,7 +552,6 @@ 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
|
||||||
|
|
@ -572,27 +571,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: InterestRate,
|
pub annual_nominal_rate: Decimal,
|
||||||
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: InterestRate,
|
pub taeg: Decimal,
|
||||||
pub schedule: &'static [MonthlyInstallment],
|
pub schedule: &'static [MonthlyInstallment],
|
||||||
}}
|
}}
|
||||||
'''
|
'''
|
||||||
|
|
@ -634,12 +619,6 @@ 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)]
|
||||||
|
|
||||||
|
|
@ -668,16 +647,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_interest_rate(offer.annual_nominal_rate)},\n"
|
f" annual_nominal_rate: {rust_decimal(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_interest_rate(offer.taeg(rows).quantize(Decimal('1e-8'), rounding=ROUND_HALF_UP))},\n"
|
f"{rust_decimal(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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue