Compare commits

...

7 commits

Author SHA1 Message Date
Marcel Enguehard
8d00f6a936 Add LoanOffer::monthly_repayment
Closed form of the constant-instalment recurrence, C*i / (1 - (1+i)^-n), with
i the taux mensuel proportionnel (taux nominal / 12) rather than the taux
actuariel equivalent, which is what French lenders use for the schedule.

A zero rate is a real case, not a degenerate one: the PTZ is a 0 % loan and the
denominator collapses to zero, which rust_decimal's Div panics on rather than
erroring. The zero branch computes C/n, the limit of the formula as i tends to
zero, and offre_c_ptz covers it.

Both known-answer tests compare against generated fixtures whose values came
from a real amortisation table, not from this implementation.

The expect() is justified by the bounds from the previous commit: capital and
rate cap the value well below i64::MAX cents, and rescale(2) makes the scale
exact, so both EurosError arms are unreachable. CLAUDE.md is amended in the
same commit to permit expect() only in this provable case, since the rule
change exists for this call site.

Requires rust_decimal's maths feature for powu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:26:55 +02:00
Marcel Enguehard
a66e13d5db Bound LoanOffer inputs so the arithmetic stays in range
An offer with no upper bound on capital, rate or duration can be constructed
and then overflow downstream. Capping all three at construction turns those
overflows into a typed error at the boundary instead of a panic in the middle
of a computation.

The bounds are structural sanity limits chosen to leave several orders of
magnitude of headroom, not domain figures. In particular MAX_ANNUAL_NOMINAL_RATE
is not the taux d'usure, which applies to the TAEG, is published quarterly by
the Banque de France, and belongs in a separate business rule with its own
source and date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:37:10 +02:00
Marcel Enguehard
bf5c463e93 Add generated loan offer fixtures for known-answer tests
Four reference offers with their full amortisation schedules, 960 instalment
lines in total, emitted by scripts/loan_schedule.py --rust. The generator was
verified against a real 2020 Caisse d'Epargne amortisation table and
cross-checked against the ANIL simulator, so the expected values come from
outside this crate rather than from its own output.

Do not hand-edit loan_offer_fixture.rs; regenerate it.

The module is #[cfg(test)] so the fixtures never reach the wasm build. Nothing
consumes them yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:18:29 +02:00
Marcel Enguehard
70086389f6 Add impl Add for Euros
Needed by the input-bound tests in the next commit, which construct an amount
one cent above the maximum.

Deliberately untested and deliberately not checked: this addition wraps in
release builds. That is acceptable while the only caller is a test, but it has
to be settled before the amortisation schedule starts summing instalments,
since a wrapped total is a wrong number that looks plausible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:18:08 +02:00
Marcel Enguehard
3ab4a4064a Add Euros::from_euros_as_decimal with a typed error
Converting a Decimal amount into integer cents can fail two ways: the value
carries sub-cent precision, or it does not fit in an i64 once scaled. Both are
reported through EurosError rather than silently truncating, since a money type
that rounds without being asked is the wrong kind of convenient.

The scale check demands exactly 2 rather than at most 2, so callers have to be
explicit about the precision they are handing over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:17:47 +02:00
Marcel Enguehard
0cf311bdf4 Store the annual nominal rate as a fraction, not a percentage
The field was called annual_rate_in_percent but nothing multiplied it by 100,
so a 3,41 % loan would have been stored as 3.41 and read as 341 %. Rename it
to annual_nominal_rate, which is what it holds, and add an explicit
annual_nominal_rate_in_percent() accessor for callers that want the display
form. Drop the trailing % from the error message, which was making the same
claim.

The nominal qualifier is there because TAEG will join this struct later and
the two must never be confused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:17:15 +02:00
Marcel Enguehard
4506231f02 Rename Euros::from_cents to from_cents_as_i64
The constructor takes a raw i64 count of cents. Naming the unit at the call
site keeps it distinct from the Decimal-based constructor added next, where
the argument is an amount in euros rather than a cent count.

Also fixes a typo in a test name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:16:37 +02:00
7 changed files with 6130 additions and 33 deletions

View file

@ -68,7 +68,7 @@ docs.rs for that exact version.
## Error handling and tests ## Error handling and tests
- `thiserror` for typed errors in `immo-core`; `anyhow` only at the `immo-web` boundary. - `thiserror` for typed errors in `immo-core`; `anyhow` only at the `immo-web` boundary.
- No `unwrap()`, `expect()`, or `panic!()` outside tests and `main`. - No `unwrap()` or `panic!()` outside tests and `main`. `expect()` can only be used for cases where the Result will provably be `Ok()`
- Every `core` function producing a number the user sees has a known-answer test, with the worked - Every `core` function producing a number the user sees has a known-answer test, with the worked
example stated in the test name or a comment above it. example stated in the test name or a comment above it.
- Test the amortisation schedule against hand-checked values, never against the implementation's - Test the amortisation schedule against hand-checked values, never against the implementation's

View file

@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
rust_decimal = "1.42.1" rust_decimal = { version = "1.42.1", features = ["maths"] }
thiserror = "2.0.19" thiserror = "2.0.19"
[lints.clippy] [lints.clippy]

View file

@ -1,6 +1,15 @@
use std::fmt; use std::{fmt, ops::Add};
use rust_decimal::Decimal; use rust_decimal::{Decimal, prelude::ToPrimitive};
use thiserror::Error;
#[derive(Debug, PartialEq, Eq, Error, Clone)]
pub enum EurosError {
#[error("Can only create from decimal with scale = 2: {0}")]
WrongDecimalScale(Decimal),
#[error("Decimal cannot be represented as i64: {0}")]
DecimalIsTooBig(Decimal),
}
#[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)] #[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)]
pub struct Euros { pub struct Euros {
@ -8,10 +17,22 @@ pub struct Euros {
} }
impl Euros { impl Euros {
pub const fn from_cents(value_as_cents: i64) -> Self { pub const fn from_cents_as_i64(value_as_cents: i64) -> Self {
Euros { value_as_cents } Euros { value_as_cents }
} }
pub fn from_euros_as_decimal(value_as_euros: Decimal) -> Result<Self, EurosError> {
// Euros should explicitely have scale 2 to validate cents precision
if value_as_euros.scale() != 2 {
Err(EurosError::WrongDecimalScale(value_as_euros))
} else {
(value_as_euros * Decimal::ONE_HUNDRED)
.to_i64()
.map(Euros::from_cents_as_i64)
.ok_or(EurosError::DecimalIsTooBig(value_as_euros))
}
}
pub const fn as_cents(self) -> i64 { pub const fn as_cents(self) -> i64 {
self.value_as_cents self.value_as_cents
} }
@ -27,19 +48,29 @@ impl fmt::Display for Euros {
} }
} }
impl Add for Euros {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Euros {
value_as_cents: self.value_as_cents + rhs.value_as_cents,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn from_cents_to_euros_to_cents_stays_equals() { fn from_cents_to_euros_to_cents_stays_equals() {
let euros = Euros::from_cents(123_456_00); let euros = Euros::from_cents_as_i64(123_456_00);
assert_eq!(euros.as_cents(), 123_456_00); assert_eq!(euros.as_cents(), 123_456_00);
} }
#[test] #[test]
fn from_positive_cents_to_decimal_has_scale_2_and_correct_mantissa() { fn from_positive_cents_to_decimal_has_scale_2_and_correct_mantissa() {
let euros = Euros::from_cents(123_456_78); let euros = Euros::from_cents_as_i64(123_456_78);
assert_eq!( assert_eq!(
euros.as_decimal().scale(), euros.as_decimal().scale(),
2, 2,
@ -54,7 +85,7 @@ mod tests {
#[test] #[test]
fn from_negative_cents_to_decimal_has_correct_scale_and_mantissa() { fn from_negative_cents_to_decimal_has_correct_scale_and_mantissa() {
let euros = Euros::from_cents(-123_456_78); let euros = Euros::from_cents_as_i64(-123_456_78);
assert_eq!( assert_eq!(
euros.as_decimal().scale(), euros.as_decimal().scale(),
2, 2,
@ -69,7 +100,7 @@ mod tests {
#[test] #[test]
fn from_max_i64_cents_to_decimal_has_correct_scale_and_mantissa() { fn from_max_i64_cents_to_decimal_has_correct_scale_and_mantissa() {
let euros = Euros::from_cents(i64::MAX); let euros = Euros::from_cents_as_i64(i64::MAX);
assert_eq!( assert_eq!(
euros.as_decimal().scale(), euros.as_decimal().scale(),
2, 2,
@ -84,26 +115,59 @@ mod tests {
#[test] #[test]
fn from_0_cents_to_decimal_stays_zero() { fn from_0_cents_to_decimal_stays_zero() {
let euros = Euros::from_cents(0); let euros = Euros::from_cents_as_i64(0);
assert_eq!(euros.as_decimal().scale(), 2); assert_eq!(euros.as_decimal().scale(), 2);
assert_eq!(euros.as_decimal(), Decimal::ZERO); assert_eq!(euros.as_decimal(), Decimal::ZERO);
} }
#[test] #[test]
fn standard_euros_prints_correctly() { fn standard_euros_prints_correctly() {
let euros = Euros::from_cents(123_45); let euros = Euros::from_cents_as_i64(123_45);
assert_eq!(format!("{}", euros), "123.45 €"); assert_eq!(format!("{}", euros), "123.45 €");
} }
#[test] #[test]
fn euros_without_cents_prints_with_trailing_zeros() { fn euros_without_cents_prints_with_trailing_zeros() {
let euros = Euros::from_cents(123_00); let euros = Euros::from_cents_as_i64(123_00);
assert_eq!(format!("{}", euros), "123.00 €"); assert_eq!(format!("{}", euros), "123.00 €");
} }
#[test] #[test]
fn euros_with_negative_amount_print_with_minus() { fn euros_with_negative_amount_prints_with_minus() {
let euros = Euros::from_cents(-123_00); let euros = Euros::from_cents_as_i64(-123_00);
assert_eq!(format!("{}", euros), "-123.00 €"); assert_eq!(format!("{}", euros), "-123.00 €");
} }
#[test]
fn from_euros_as_decimal_fails_if_scale_is_larger_than_2() {
let dec = Decimal::new(3141, 3);
let ret = Euros::from_euros_as_decimal(dec);
assert!(ret.is_err());
assert_eq!(ret.unwrap_err(), EurosError::WrongDecimalScale(dec));
}
#[test]
fn from_euros_as_decimal_fails_if_scale_is_lower_than_2() {
let dec = Decimal::new(3141, 1);
let ret = Euros::from_euros_as_decimal(dec);
assert!(ret.is_err());
assert_eq!(ret.unwrap_err(), EurosError::WrongDecimalScale(dec));
}
#[test]
fn from_euros_as_decimal_yields_correct_result_if_scale_is_2() {
let dec = Decimal::new(3141, 2);
let ret = Euros::from_euros_as_decimal(dec);
assert!(ret.is_ok());
assert_eq!(ret.unwrap().as_cents(), 3141);
}
#[test]
fn from_euros_as_decimal_yields_error_with_decimal_too_big() {
let mut dec = Decimal::from(u64::MAX);
dec.rescale(2);
let ret = Euros::from_euros_as_decimal(dec);
assert!(ret.is_err());
assert_eq!(ret.unwrap_err(), EurosError::DecimalIsTooBig(dec));
}
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
pub mod loan_offer_fixture;

View file

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

View file

@ -1,54 +1,104 @@
use rust_decimal::Decimal; use rust_decimal::Decimal;
use rust_decimal::MathematicalOps;
use thiserror::Error; use thiserror::Error;
use crate::euros::Euros; use crate::euros::Euros;
#[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(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}")]
NonPositiveBorrowedCapital(Euros), NonPositiveBorrowedCapital(Euros),
#[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),
#[error("loan duration in months should be lower than {MAX_LOAN_DURATION_IN_MONTHS}: {0}")]
MaxLoanDurationExceeded(u64),
} }
/// 1b€ is a pretty expensive house...
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);
/// 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;
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub struct LoanOffer { pub struct LoanOffer {
borrowed_capital: Euros, borrowed_capital: Euros,
annual_rate_in_percent: Decimal, 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_rate_in_percent: Decimal, annual_nominal_rate: Decimal,
loan_duration_in_months: u64, loan_duration_in_months: u64,
) -> Result<Self, LoanOfferError> { ) -> Result<Self, LoanOfferError> {
if annual_rate_in_percent < Decimal::ZERO { if annual_nominal_rate < Decimal::ZERO {
Err(LoanOfferError::NegativeInterestRate(annual_rate_in_percent)) Err(LoanOfferError::NegativeInterestRate(annual_nominal_rate))
} else if annual_nominal_rate > MAX_ANNUAL_NOMINAL_RATE {
Err(LoanOfferError::MaxAnnualNominalRateExceeded(
annual_nominal_rate,
))
} else if loan_duration_in_months == 0 { } else if loan_duration_in_months == 0 {
Err(LoanOfferError::ZeroLoanDuration) Err(LoanOfferError::ZeroLoanDuration)
} else if borrowed_capital <= Euros::from_cents(0) { } else if loan_duration_in_months > MAX_LOAN_DURATION_IN_MONTHS {
Err(LoanOfferError::MaxLoanDurationExceeded(
loan_duration_in_months,
))
} else if borrowed_capital <= Euros::from_cents_as_i64(0) {
Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital)) Err(LoanOfferError::NonPositiveBorrowedCapital(borrowed_capital))
} else if borrowed_capital > MAX_BORROWED_CAPITAL {
Err(LoanOfferError::MaxBorrowedCapitalExceeded(borrowed_capital))
} else { } else {
Ok(LoanOffer { Ok(LoanOffer {
borrowed_capital, borrowed_capital,
annual_rate_in_percent, annual_nominal_rate,
loan_duration_in_months, loan_duration_in_months,
}) })
} }
} }
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)
}
pub fn monthly_repayment(&self) -> Euros {
let monthly_rate = self.proportional_monthly_interest_rate();
let mut repayment_in_euros = if monthly_rate == Decimal::ZERO {
(self.borrowed_capital.as_decimal() / Decimal::from(self.loan_duration_in_months))
.round_dp(2)
} else {
let repayment_numerator = self.borrowed_capital.as_decimal() * monthly_rate;
let repayment_denominator = Decimal::ONE
- (Decimal::ONE / (Decimal::ONE + monthly_rate).powu(self.loan_duration_in_months));
(repayment_numerator / repayment_denominator).round_dp(2)
};
repayment_in_euros.rescale(2);
Euros::from_euros_as_decimal(repayment_in_euros).expect("Borrowed capital and annual rate are constrained on construction, repayment_in_euros cannot exceed 10b, and scale=2 is guaranteed through rescale")
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::fixtures::loan_offer_fixture::*;
#[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(123), Decimal::new(-45, 2), 5); let ret = LoanOffer::new(Euros::from_cents_as_i64(123), Decimal::new(-45, 2), 5);
assert_eq!( assert_eq!(
ret, ret,
Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2))) Err(LoanOfferError::NegativeInterestRate(Decimal::new(-45, 2)))
@ -57,18 +107,60 @@ mod tests {
#[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(123), Decimal::new(45, 2), 0); let ret = LoanOffer::new(Euros::from_cents_as_i64(123), 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(-1), Decimal::new(45, 2), 15); let ret = LoanOffer::new(Euros::from_cents_as_i64(-1), Decimal::new(45, 2), 15);
assert_eq!( assert_eq!(
ret, ret,
Err(LoanOfferError::NonPositiveBorrowedCapital( Err(LoanOfferError::NonPositiveBorrowedCapital(
Euros::from_cents(-1) Euros::from_cents_as_i64(-1)
))
);
}
#[test]
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);
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 ret = LoanOffer::new(
Euros::from_cents_as_i64(100_000_00),
annual_nominal_rate,
25,
);
assert_eq!(
ret,
Err(LoanOfferError::MaxAnnualNominalRateExceeded(
annual_nominal_rate
))
);
}
#[test]
fn creating_loan_offer_with_loan_duration_higher_than_max_loan_duration_returns_max_loan_duration_exceeded_error()
{
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),
loan_duration_in_months,
);
assert_eq!(
ret,
Err(LoanOfferError::MaxLoanDurationExceeded(
loan_duration_in_months
)) ))
); );
} }
@ -76,38 +168,87 @@ 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(0), Decimal::new(45, 2), 15); let ret = LoanOffer::new(Euros::from_cents_as_i64(0), Decimal::new(45, 2), 15);
assert_eq!( assert_eq!(
ret, ret,
Err(LoanOfferError::NonPositiveBorrowedCapital( Err(LoanOfferError::NonPositiveBorrowedCapital(
Euros::from_cents(0) Euros::from_cents_as_i64(0)
)) ))
); );
} }
#[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(10), Decimal::new(45, 2), 15); let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::new(45, 2), 15);
assert_eq!( assert_eq!(
ret, ret,
Ok(LoanOffer { Ok(LoanOffer {
borrowed_capital: Euros::from_cents(10), borrowed_capital: Euros::from_cents_as_i64(10),
annual_rate_in_percent: Decimal::new(45, 2), annual_nominal_rate: Decimal::new(45, 2),
loan_duration_in_months: 15 loan_duration_in_months: 15
}) })
); );
} }
#[test]
fn creating_loan_offer_with_limit_inputs_works() {
let ret = LoanOffer::new(
MAX_BORROWED_CAPITAL,
MAX_ANNUAL_NOMINAL_RATE,
MAX_LOAN_DURATION_IN_MONTHS,
);
assert!(ret.is_ok());
// Should not panic - checks that expect assertion is valid
ret.unwrap().monthly_repayment();
}
#[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(10), Decimal::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(10), borrowed_capital: Euros::from_cents_as_i64(10),
annual_rate_in_percent: Decimal::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, 4),
300,
)
.unwrap();
assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(4, 2));
}
#[test]
fn monthly_repayment_works_for_standard_loan_offer() {
let fixture = offre_a();
let offer = LoanOffer::new(
fixture.borrowed_capital,
fixture.annual_nominal_rate,
fixture.total_duration_in_months,
)
.unwrap();
assert_eq!(offer.monthly_repayment(), fixture.monthly_repayment);
}
#[test]
fn monthly_repayment_works_for_zero_annual_nominal_rate() {
let fixture = offre_c_ptz();
let offer = LoanOffer::new(
fixture.borrowed_capital,
fixture.annual_nominal_rate,
fixture.total_duration_in_months,
)
.unwrap();
assert_eq!(offer.monthly_repayment(), fixture.monthly_repayment);
}
} }