should-i-rent/crates/immo-core/src/interest_rate.rs

74 lines
1.8 KiB
Rust
Raw Normal View History

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