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>
This commit is contained in:
parent
a66e13d5db
commit
8d00f6a936
3 changed files with 61 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -69,11 +70,31 @@ impl LoanOffer {
|
||||||
pub fn annual_nominal_rate_in_percent(&self) -> Decimal {
|
pub fn annual_nominal_rate_in_percent(&self) -> Decimal {
|
||||||
self.annual_nominal_rate * Decimal::ONE_HUNDRED
|
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() {
|
||||||
|
|
@ -169,6 +190,18 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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_as_i64(10), Decimal::ZERO, 15);
|
let ret = LoanOffer::new(Euros::from_cents_as_i64(10), Decimal::ZERO, 15);
|
||||||
|
|
@ -192,4 +225,30 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(offer.annual_nominal_rate_in_percent(), Decimal::new(4, 2));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue